Add Ed25519 envelope signing infrastructure
Implement cryptographic signing for message envelopes using Ed25519: - Identity module: Generate, load, save Ed25519 keypairs - Signing module: Sign/verify envelopes using Exclusive C14N - Envelope utilities: Build envelopes with optional signing - CLI keygen command: xml-pipeline keygen [-o path] - Pump integration: Auto-sign when identity configured Signature is embedded in <meta> block using namespace https://xml-pipeline.org/ns/sig/v1, fitting existing xs:any in envelope.xsd. Usage: xml-pipeline keygen -o config/identity.key # organism.yaml organism: identity: "config/identity.key" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ab52debf8b
commit
809862af35
7 changed files with 1243 additions and 4 deletions
431
tests/test_crypto.py
Normal file
431
tests/test_crypto.py
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
"""
|
||||||
|
test_crypto.py — Tests for Ed25519 identity and envelope signing.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
1. Key generation and loading
|
||||||
|
2. Envelope signing
|
||||||
|
3. Signature verification
|
||||||
|
4. Tamper detection
|
||||||
|
5. Edge cases
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from xml_pipeline.crypto.identity import (
|
||||||
|
Identity,
|
||||||
|
IdentityError,
|
||||||
|
generate_identity,
|
||||||
|
load_public_key,
|
||||||
|
verify_with_public_key,
|
||||||
|
)
|
||||||
|
from xml_pipeline.crypto.signing import (
|
||||||
|
SIGNATURE_NAMESPACE,
|
||||||
|
SignatureError,
|
||||||
|
extract_signature,
|
||||||
|
is_signed,
|
||||||
|
sign_envelope,
|
||||||
|
strip_signature,
|
||||||
|
verify_envelope,
|
||||||
|
verify_envelope_with_identity,
|
||||||
|
)
|
||||||
|
from xml_pipeline.message_bus.envelope import (
|
||||||
|
build_envelope,
|
||||||
|
envelope_to_bytes,
|
||||||
|
extract_meta,
|
||||||
|
extract_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdentityGeneration:
|
||||||
|
"""Test identity key generation."""
|
||||||
|
|
||||||
|
def test_generate_creates_valid_keypair(self):
|
||||||
|
"""generate_identity() should create a valid Ed25519 keypair."""
|
||||||
|
identity = generate_identity()
|
||||||
|
|
||||||
|
assert identity.private_key is not None
|
||||||
|
assert identity.public_key is not None
|
||||||
|
assert identity.path == "<generated>"
|
||||||
|
|
||||||
|
def test_generated_key_can_sign_and_verify(self):
|
||||||
|
"""Generated identity should be able to sign and verify."""
|
||||||
|
identity = generate_identity()
|
||||||
|
data = b"test message"
|
||||||
|
|
||||||
|
signature = identity.sign(data)
|
||||||
|
|
||||||
|
assert len(signature) == 64 # Ed25519 signatures are 64 bytes
|
||||||
|
assert identity.verify(signature, data) is True
|
||||||
|
|
||||||
|
def test_signature_fails_for_wrong_data(self):
|
||||||
|
"""Verification should fail for modified data."""
|
||||||
|
identity = generate_identity()
|
||||||
|
data = b"original message"
|
||||||
|
signature = identity.sign(data)
|
||||||
|
|
||||||
|
assert identity.verify(signature, b"modified message") is False
|
||||||
|
|
||||||
|
def test_sign_base64_produces_valid_encoding(self):
|
||||||
|
"""sign_base64() should produce valid base64."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
identity = generate_identity()
|
||||||
|
data = b"test"
|
||||||
|
|
||||||
|
sig_b64 = identity.sign_base64(data)
|
||||||
|
|
||||||
|
# Should decode without error
|
||||||
|
decoded = base64.b64decode(sig_b64)
|
||||||
|
assert len(decoded) == 64
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdentityPersistence:
|
||||||
|
"""Test saving and loading identity keys."""
|
||||||
|
|
||||||
|
def test_save_and_load_roundtrip(self):
|
||||||
|
"""Identity should survive save/load cycle."""
|
||||||
|
identity = generate_identity()
|
||||||
|
data = b"test data to sign"
|
||||||
|
original_sig = identity.sign(data)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
private_path = Path(tmpdir) / "test.key"
|
||||||
|
public_path = Path(tmpdir) / "test.pub"
|
||||||
|
|
||||||
|
identity.save(private_path, public_path)
|
||||||
|
|
||||||
|
# Files should exist
|
||||||
|
assert private_path.exists()
|
||||||
|
assert public_path.exists()
|
||||||
|
|
||||||
|
# Load and verify
|
||||||
|
loaded = Identity.load(private_path)
|
||||||
|
loaded_sig = loaded.sign(data)
|
||||||
|
|
||||||
|
# Signatures should match
|
||||||
|
assert original_sig == loaded_sig
|
||||||
|
|
||||||
|
def test_load_nonexistent_raises_error(self):
|
||||||
|
"""Loading non-existent key should raise IdentityError."""
|
||||||
|
with pytest.raises(IdentityError, match="not found"):
|
||||||
|
Identity.load("/nonexistent/path/key.pem")
|
||||||
|
|
||||||
|
def test_load_invalid_key_raises_error(self):
|
||||||
|
"""Loading invalid key data should raise IdentityError."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="wb", suffix=".pem", delete=False) as f:
|
||||||
|
f.write(b"not a valid key")
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
with pytest.raises(IdentityError, match="Failed to load"):
|
||||||
|
Identity.load(f.name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicKeyLoading:
|
||||||
|
"""Test loading public keys for verification."""
|
||||||
|
|
||||||
|
def test_load_public_key(self):
|
||||||
|
"""load_public_key() should load a valid public key."""
|
||||||
|
identity = generate_identity()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
private_path = Path(tmpdir) / "test.key"
|
||||||
|
public_path = Path(tmpdir) / "test.pub"
|
||||||
|
identity.save(private_path, public_path)
|
||||||
|
|
||||||
|
# Load public key
|
||||||
|
pubkey = load_public_key(public_path)
|
||||||
|
|
||||||
|
# Should verify signatures
|
||||||
|
data = b"test"
|
||||||
|
sig = identity.sign(data)
|
||||||
|
assert verify_with_public_key(pubkey, sig, data) is True
|
||||||
|
|
||||||
|
def test_load_public_key_nonexistent(self):
|
||||||
|
"""Loading non-existent public key should raise error."""
|
||||||
|
with pytest.raises(IdentityError, match="not found"):
|
||||||
|
load_public_key("/nonexistent/pubkey.pub")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvelopeSigning:
|
||||||
|
"""Test envelope signing with Ed25519."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_envelope(self):
|
||||||
|
"""Create a sample unsigned envelope."""
|
||||||
|
ns = "https://xml-pipeline.org/ns/envelope/v1"
|
||||||
|
nsmap = {None: ns}
|
||||||
|
|
||||||
|
message = etree.Element(f"{{{ns}}}message", nsmap=nsmap)
|
||||||
|
meta = etree.SubElement(message, f"{{{ns}}}meta")
|
||||||
|
|
||||||
|
from_elem = etree.SubElement(meta, f"{{{ns}}}from")
|
||||||
|
from_elem.text = "greeter"
|
||||||
|
|
||||||
|
thread_elem = etree.SubElement(meta, f"{{{ns}}}thread")
|
||||||
|
thread_elem.text = "test-thread-uuid"
|
||||||
|
|
||||||
|
# Add a payload
|
||||||
|
payload = etree.SubElement(message, "Greeting")
|
||||||
|
name_elem = etree.SubElement(payload, "name")
|
||||||
|
name_elem.text = "Alice"
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def identity(self):
|
||||||
|
"""Create a test identity."""
|
||||||
|
return generate_identity()
|
||||||
|
|
||||||
|
def test_sign_envelope_adds_signature(self, sample_envelope, identity):
|
||||||
|
"""sign_envelope() should add a signature element."""
|
||||||
|
signed = sign_envelope(sample_envelope, identity)
|
||||||
|
|
||||||
|
sig = extract_signature(signed)
|
||||||
|
assert sig is not None
|
||||||
|
assert len(sig) > 0
|
||||||
|
|
||||||
|
def test_sign_envelope_creates_copy_by_default(self, sample_envelope, identity):
|
||||||
|
"""sign_envelope() should not modify original by default."""
|
||||||
|
original_bytes = etree.tostring(sample_envelope)
|
||||||
|
|
||||||
|
sign_envelope(sample_envelope, identity)
|
||||||
|
|
||||||
|
# Original should be unchanged
|
||||||
|
assert etree.tostring(sample_envelope) == original_bytes
|
||||||
|
|
||||||
|
def test_sign_envelope_in_place(self, sample_envelope, identity):
|
||||||
|
"""sign_envelope(in_place=True) should modify original."""
|
||||||
|
sign_envelope(sample_envelope, identity, in_place=True)
|
||||||
|
|
||||||
|
sig = extract_signature(sample_envelope)
|
||||||
|
assert sig is not None
|
||||||
|
|
||||||
|
def test_signed_envelope_verifies(self, sample_envelope, identity):
|
||||||
|
"""Signed envelope should verify with same identity."""
|
||||||
|
signed = sign_envelope(sample_envelope, identity)
|
||||||
|
|
||||||
|
assert verify_envelope_with_identity(signed, identity) is True
|
||||||
|
|
||||||
|
def test_signed_envelope_verifies_with_public_key(self, sample_envelope, identity):
|
||||||
|
"""Signed envelope should verify with public key."""
|
||||||
|
signed = sign_envelope(sample_envelope, identity)
|
||||||
|
|
||||||
|
assert verify_envelope(signed, identity.public_key) is True
|
||||||
|
|
||||||
|
def test_tampered_envelope_fails_verification(self, sample_envelope, identity):
|
||||||
|
"""Modifying signed envelope should fail verification."""
|
||||||
|
signed = sign_envelope(sample_envelope, identity)
|
||||||
|
|
||||||
|
# Tamper with the payload
|
||||||
|
ns = "https://xml-pipeline.org/ns/envelope/v1"
|
||||||
|
meta = signed.find(f"{{{ns}}}meta")
|
||||||
|
from_elem = meta.find(f"{{{ns}}}from")
|
||||||
|
from_elem.text = "evil-agent"
|
||||||
|
|
||||||
|
assert verify_envelope(signed, identity.public_key) is False
|
||||||
|
|
||||||
|
def test_wrong_key_fails_verification(self, sample_envelope, identity):
|
||||||
|
"""Verification with wrong key should fail."""
|
||||||
|
signed = sign_envelope(sample_envelope, identity)
|
||||||
|
|
||||||
|
other_identity = generate_identity()
|
||||||
|
assert verify_envelope(signed, other_identity.public_key) is False
|
||||||
|
|
||||||
|
def test_unsigned_envelope_fails_verification(self, sample_envelope, identity):
|
||||||
|
"""Unsigned envelope should fail verification."""
|
||||||
|
assert verify_envelope(sample_envelope, identity.public_key) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignatureHelpers:
|
||||||
|
"""Test signature utility functions."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def signed_envelope(self):
|
||||||
|
"""Create a signed envelope."""
|
||||||
|
identity = generate_identity()
|
||||||
|
envelope = build_envelope(
|
||||||
|
payload=b"<Test>data</Test>",
|
||||||
|
from_id="sender",
|
||||||
|
thread_id="thread-123",
|
||||||
|
)
|
||||||
|
return sign_envelope(envelope, identity)
|
||||||
|
|
||||||
|
def test_is_signed_true_for_signed(self, signed_envelope):
|
||||||
|
"""is_signed() should return True for signed envelope."""
|
||||||
|
assert is_signed(signed_envelope) is True
|
||||||
|
|
||||||
|
def test_is_signed_false_for_unsigned(self):
|
||||||
|
"""is_signed() should return False for unsigned envelope."""
|
||||||
|
envelope = build_envelope(
|
||||||
|
payload=b"<Test>data</Test>",
|
||||||
|
from_id="sender",
|
||||||
|
thread_id="thread-123",
|
||||||
|
)
|
||||||
|
assert is_signed(envelope) is False
|
||||||
|
|
||||||
|
def test_extract_signature_returns_base64(self, signed_envelope):
|
||||||
|
"""extract_signature() should return base64 string."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
sig = extract_signature(signed_envelope)
|
||||||
|
|
||||||
|
assert sig is not None
|
||||||
|
decoded = base64.b64decode(sig)
|
||||||
|
assert len(decoded) == 64
|
||||||
|
|
||||||
|
def test_strip_signature_removes_sig(self, signed_envelope):
|
||||||
|
"""strip_signature() should remove signature element."""
|
||||||
|
stripped = strip_signature(signed_envelope)
|
||||||
|
|
||||||
|
assert is_signed(stripped) is False
|
||||||
|
|
||||||
|
def test_strip_signature_preserves_original(self, signed_envelope):
|
||||||
|
"""strip_signature() should not modify original by default."""
|
||||||
|
strip_signature(signed_envelope)
|
||||||
|
|
||||||
|
assert is_signed(signed_envelope) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvelopeBuilder:
|
||||||
|
"""Test envelope construction utilities."""
|
||||||
|
|
||||||
|
def test_build_envelope_creates_valid_structure(self):
|
||||||
|
"""build_envelope() should create valid envelope structure."""
|
||||||
|
envelope = build_envelope(
|
||||||
|
payload=b"<Greeting><name>Alice</name></Greeting>",
|
||||||
|
from_id="greeter",
|
||||||
|
thread_id="thread-abc",
|
||||||
|
to_id="shouter",
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = extract_meta(envelope)
|
||||||
|
assert meta["from_id"] == "greeter"
|
||||||
|
assert meta["to_id"] == "shouter"
|
||||||
|
assert meta["thread_id"] == "thread-abc"
|
||||||
|
|
||||||
|
payload = extract_payload(envelope)
|
||||||
|
assert payload is not None
|
||||||
|
assert payload.tag == "Greeting"
|
||||||
|
|
||||||
|
def test_build_envelope_without_to(self):
|
||||||
|
"""build_envelope() should work without to_id."""
|
||||||
|
envelope = build_envelope(
|
||||||
|
payload=b"<Test/>",
|
||||||
|
from_id="sender",
|
||||||
|
thread_id="thread-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = extract_meta(envelope)
|
||||||
|
assert meta["from_id"] == "sender"
|
||||||
|
assert meta["to_id"] is None
|
||||||
|
|
||||||
|
def test_build_envelope_with_signing(self):
|
||||||
|
"""build_envelope() should sign when identity provided."""
|
||||||
|
identity = generate_identity()
|
||||||
|
|
||||||
|
envelope = build_envelope(
|
||||||
|
payload=b"<Test/>",
|
||||||
|
from_id="sender",
|
||||||
|
thread_id="thread-123",
|
||||||
|
identity=identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert is_signed(envelope) is True
|
||||||
|
assert verify_envelope(envelope, identity.public_key) is True
|
||||||
|
|
||||||
|
def test_envelope_to_bytes_produces_xml(self):
|
||||||
|
"""envelope_to_bytes() should produce valid XML bytes."""
|
||||||
|
envelope = build_envelope(
|
||||||
|
payload=b"<Test/>",
|
||||||
|
from_id="sender",
|
||||||
|
thread_id="thread-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
xml_bytes = envelope_to_bytes(envelope)
|
||||||
|
|
||||||
|
assert xml_bytes.startswith(b"<?xml")
|
||||||
|
assert b"<message" in xml_bytes
|
||||||
|
|
||||||
|
|
||||||
|
class TestSigningEdgeCases:
|
||||||
|
"""Test edge cases in signing."""
|
||||||
|
|
||||||
|
def test_re_signing_replaces_signature(self):
|
||||||
|
"""Signing an already-signed envelope should replace signature."""
|
||||||
|
identity1 = generate_identity()
|
||||||
|
identity2 = generate_identity()
|
||||||
|
|
||||||
|
envelope = build_envelope(
|
||||||
|
payload=b"<Test/>",
|
||||||
|
from_id="sender",
|
||||||
|
thread_id="thread-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sign with first identity
|
||||||
|
signed1 = sign_envelope(envelope, identity1)
|
||||||
|
sig1 = extract_signature(signed1)
|
||||||
|
|
||||||
|
# Re-sign with second identity
|
||||||
|
signed2 = sign_envelope(signed1, identity2)
|
||||||
|
sig2 = extract_signature(signed2)
|
||||||
|
|
||||||
|
# Signatures should be different
|
||||||
|
assert sig1 != sig2
|
||||||
|
|
||||||
|
# Should verify with second identity only
|
||||||
|
assert verify_envelope(signed2, identity1.public_key) is False
|
||||||
|
assert verify_envelope(signed2, identity2.public_key) is True
|
||||||
|
|
||||||
|
def test_sign_envelope_missing_meta_raises(self):
|
||||||
|
"""Signing envelope without meta should raise error."""
|
||||||
|
identity = generate_identity()
|
||||||
|
|
||||||
|
# Create envelope without meta
|
||||||
|
envelope = etree.Element("message")
|
||||||
|
payload = etree.SubElement(envelope, "Test")
|
||||||
|
payload.text = "data"
|
||||||
|
|
||||||
|
with pytest.raises(SignatureError, match="missing <meta>"):
|
||||||
|
sign_envelope(envelope, identity)
|
||||||
|
|
||||||
|
def test_empty_payload_signs_correctly(self):
|
||||||
|
"""Empty payload should still sign correctly."""
|
||||||
|
identity = generate_identity()
|
||||||
|
|
||||||
|
envelope = build_envelope(
|
||||||
|
payload=b"<Empty/>",
|
||||||
|
from_id="sender",
|
||||||
|
thread_id="thread-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
signed = sign_envelope(envelope, identity)
|
||||||
|
assert verify_envelope(signed, identity.public_key) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicKeyFormats:
|
||||||
|
"""Test public key export formats."""
|
||||||
|
|
||||||
|
def test_get_public_key_pem(self):
|
||||||
|
"""get_public_key_pem() should return valid PEM."""
|
||||||
|
identity = generate_identity()
|
||||||
|
|
||||||
|
pem = identity.get_public_key_pem()
|
||||||
|
|
||||||
|
assert pem.startswith(b"-----BEGIN PUBLIC KEY-----")
|
||||||
|
assert pem.endswith(b"-----END PUBLIC KEY-----\n")
|
||||||
|
|
||||||
|
def test_get_public_key_base64(self):
|
||||||
|
"""get_public_key_base64() should return compact base64."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
identity = generate_identity()
|
||||||
|
|
||||||
|
b64 = identity.get_public_key_base64()
|
||||||
|
|
||||||
|
# Should decode to 32 bytes (Ed25519 public key size)
|
||||||
|
decoded = base64.b64decode(b64)
|
||||||
|
assert len(decoded) == 32
|
||||||
|
|
@ -6,6 +6,7 @@ Usage:
|
||||||
xml-pipeline init [name] Create new organism config
|
xml-pipeline init [name] Create new organism config
|
||||||
xml-pipeline check [config.yaml] Validate config without running
|
xml-pipeline check [config.yaml] Validate config without running
|
||||||
xml-pipeline version Show version info
|
xml-pipeline version Show version info
|
||||||
|
xml-pipeline keygen [-o path] Generate Ed25519 identity keypair
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -100,6 +101,41 @@ def cmd_version(args: argparse.Namespace) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_keygen(args: argparse.Namespace) -> int:
|
||||||
|
"""Generate Ed25519 identity keypair."""
|
||||||
|
from xml_pipeline.crypto import generate_identity
|
||||||
|
|
||||||
|
output = Path(args.output)
|
||||||
|
|
||||||
|
# Prevent overwrite unless forced
|
||||||
|
if output.exists() and not args.force:
|
||||||
|
print(f"Error: {output} already exists. Use --force to overwrite.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
public_path = output.with_suffix(".pub")
|
||||||
|
if public_path.exists() and not args.force:
|
||||||
|
print(f"Error: {public_path} already exists. Use --force to overwrite.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
identity = generate_identity()
|
||||||
|
identity.save(output, public_path)
|
||||||
|
|
||||||
|
print(f"Generated Ed25519 identity keypair:")
|
||||||
|
print(f" Private key: {output}")
|
||||||
|
print(f" Public key: {public_path}")
|
||||||
|
print()
|
||||||
|
print(f"Add to organism.yaml:")
|
||||||
|
print(f" organism:")
|
||||||
|
print(f" identity: \"{output}\"")
|
||||||
|
print()
|
||||||
|
print("IMPORTANT: Keep the private key secure. Never commit it to version control.")
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating keys: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""CLI entry point."""
|
"""CLI entry point."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|
@ -129,6 +165,16 @@ def main() -> int:
|
||||||
version_parser = subparsers.add_parser("version", help="Show version info")
|
version_parser = subparsers.add_parser("version", help="Show version info")
|
||||||
version_parser.set_defaults(func=cmd_version)
|
version_parser.set_defaults(func=cmd_version)
|
||||||
|
|
||||||
|
# keygen
|
||||||
|
keygen_parser = subparsers.add_parser("keygen", help="Generate Ed25519 identity keypair")
|
||||||
|
keygen_parser.add_argument(
|
||||||
|
"-o", "--output",
|
||||||
|
default="identity.key",
|
||||||
|
help="Output path for private key (default: identity.key)",
|
||||||
|
)
|
||||||
|
keygen_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
|
||||||
|
keygen_parser.set_defaults(func=cmd_keygen)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
return args.func(args)
|
return args.func(args)
|
||||||
|
|
||||||
|
|
|
||||||
46
xml_pipeline/crypto/__init__.py
Normal file
46
xml_pipeline/crypto/__init__.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""
|
||||||
|
crypto — Ed25519 identity keys for signing and verification.
|
||||||
|
|
||||||
|
This module provides:
|
||||||
|
- Identity key generation and loading
|
||||||
|
- Envelope signing using Exclusive C14N
|
||||||
|
- Signature verification for incoming messages
|
||||||
|
- Federation peer authentication
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from xml_pipeline.crypto import Identity, sign_envelope, verify_envelope
|
||||||
|
|
||||||
|
# Load organism identity
|
||||||
|
identity = Identity.load("config/identity/private.ed25519")
|
||||||
|
|
||||||
|
# Sign an envelope
|
||||||
|
signed_envelope = sign_envelope(envelope_tree, identity)
|
||||||
|
|
||||||
|
# Verify with peer's public key
|
||||||
|
is_valid = verify_envelope(envelope_tree, peer_public_key)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from xml_pipeline.crypto.identity import (
|
||||||
|
Identity,
|
||||||
|
generate_identity,
|
||||||
|
load_public_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
from xml_pipeline.crypto.signing import (
|
||||||
|
sign_envelope,
|
||||||
|
verify_envelope,
|
||||||
|
extract_signature,
|
||||||
|
SIGNATURE_NAMESPACE,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Identity
|
||||||
|
"Identity",
|
||||||
|
"generate_identity",
|
||||||
|
"load_public_key",
|
||||||
|
# Signing
|
||||||
|
"sign_envelope",
|
||||||
|
"verify_envelope",
|
||||||
|
"extract_signature",
|
||||||
|
"SIGNATURE_NAMESPACE",
|
||||||
|
]
|
||||||
265
xml_pipeline/crypto/identity.py
Normal file
265
xml_pipeline/crypto/identity.py
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
"""
|
||||||
|
identity.py — Ed25519 key management for organism identity.
|
||||||
|
|
||||||
|
Each organism has a unique Ed25519 keypair:
|
||||||
|
- Private key: Stored securely, used for signing outgoing messages
|
||||||
|
- Public key: Shared with federation peers for verification
|
||||||
|
|
||||||
|
Key files use PEM format for compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||||
|
Ed25519PrivateKey,
|
||||||
|
Ed25519PublicKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityError(Exception):
|
||||||
|
"""Raised when identity key operations fail."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Identity:
|
||||||
|
"""
|
||||||
|
Organism identity backed by Ed25519 keypair.
|
||||||
|
|
||||||
|
The identity is used for:
|
||||||
|
- Signing outgoing message envelopes
|
||||||
|
- Verifying incoming messages from known peers
|
||||||
|
- Federation peer authentication
|
||||||
|
"""
|
||||||
|
|
||||||
|
private_key: Ed25519PrivateKey
|
||||||
|
public_key: Ed25519PublicKey
|
||||||
|
path: str = "" # Where the key was loaded from (for logging)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: str | Path) -> Identity:
|
||||||
|
"""
|
||||||
|
Load identity from a PEM-encoded private key file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the private key file (e.g., "config/identity/private.ed25519")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Identity instance with loaded keypair
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
IdentityError: If the key file doesn't exist or is invalid
|
||||||
|
"""
|
||||||
|
key_path = Path(path)
|
||||||
|
|
||||||
|
if not key_path.exists():
|
||||||
|
raise IdentityError(f"Identity key not found: {path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pem_data = key_path.read_bytes()
|
||||||
|
private_key = serialization.load_pem_private_key(pem_data, password=None)
|
||||||
|
|
||||||
|
if not isinstance(private_key, Ed25519PrivateKey):
|
||||||
|
raise IdentityError(f"Key is not Ed25519: {path}")
|
||||||
|
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
private_key=private_key,
|
||||||
|
public_key=public_key,
|
||||||
|
path=str(path),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, IdentityError):
|
||||||
|
raise
|
||||||
|
raise IdentityError(f"Failed to load identity key: {e}") from e
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate(cls) -> Identity:
|
||||||
|
"""
|
||||||
|
Generate a new Ed25519 identity keypair.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Identity instance with freshly generated keypair
|
||||||
|
"""
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
private_key=private_key,
|
||||||
|
public_key=public_key,
|
||||||
|
path="<generated>",
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, private_path: str | Path, public_path: str | Path | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Save the identity keypair to PEM files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
private_path: Where to save the private key
|
||||||
|
public_path: Where to save the public key (optional, derived if not given)
|
||||||
|
"""
|
||||||
|
private_path = Path(private_path)
|
||||||
|
|
||||||
|
# Ensure parent directory exists
|
||||||
|
private_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save private key
|
||||||
|
private_pem = self.private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
private_path.write_bytes(private_pem)
|
||||||
|
|
||||||
|
# Save public key
|
||||||
|
if public_path is None:
|
||||||
|
public_path = private_path.with_suffix(".pub")
|
||||||
|
else:
|
||||||
|
public_path = Path(public_path)
|
||||||
|
|
||||||
|
public_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
public_pem = self.public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
public_path.write_bytes(public_pem)
|
||||||
|
|
||||||
|
self.path = str(private_path)
|
||||||
|
|
||||||
|
def sign(self, data: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Sign data with the private key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw bytes to sign
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
64-byte Ed25519 signature
|
||||||
|
"""
|
||||||
|
return self.private_key.sign(data)
|
||||||
|
|
||||||
|
def sign_base64(self, data: bytes) -> str:
|
||||||
|
"""
|
||||||
|
Sign data and return base64-encoded signature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw bytes to sign
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded signature string
|
||||||
|
"""
|
||||||
|
signature = self.sign(data)
|
||||||
|
return base64.b64encode(signature).decode("ascii")
|
||||||
|
|
||||||
|
def verify(self, signature: bytes, data: bytes) -> bool:
|
||||||
|
"""
|
||||||
|
Verify a signature against data using our public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signature: 64-byte Ed25519 signature
|
||||||
|
data: Original signed data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if signature is valid, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.public_key.verify(signature, data)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_public_key_pem(self) -> bytes:
|
||||||
|
"""Get the public key in PEM format for sharing."""
|
||||||
|
return self.public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_public_key_base64(self) -> str:
|
||||||
|
"""Get the raw public key bytes as base64 (compact format)."""
|
||||||
|
raw_bytes = self.public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
return base64.b64encode(raw_bytes).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_identity() -> Identity:
|
||||||
|
"""
|
||||||
|
Generate a new Ed25519 identity.
|
||||||
|
|
||||||
|
Convenience function wrapping Identity.generate().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New Identity instance
|
||||||
|
"""
|
||||||
|
return Identity.generate()
|
||||||
|
|
||||||
|
|
||||||
|
def load_public_key(path: str | Path) -> Ed25519PublicKey:
|
||||||
|
"""
|
||||||
|
Load a public key from a PEM file.
|
||||||
|
|
||||||
|
Used for loading federation peer public keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to public key file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ed25519 public key
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
IdentityError: If file doesn't exist or key is invalid
|
||||||
|
"""
|
||||||
|
key_path = Path(path)
|
||||||
|
|
||||||
|
if not key_path.exists():
|
||||||
|
raise IdentityError(f"Public key not found: {path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pem_data = key_path.read_bytes()
|
||||||
|
public_key = serialization.load_pem_public_key(pem_data)
|
||||||
|
|
||||||
|
if not isinstance(public_key, Ed25519PublicKey):
|
||||||
|
raise IdentityError(f"Key is not Ed25519: {path}")
|
||||||
|
|
||||||
|
return public_key
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, IdentityError):
|
||||||
|
raise
|
||||||
|
raise IdentityError(f"Failed to load public key: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def verify_with_public_key(
|
||||||
|
public_key: Ed25519PublicKey,
|
||||||
|
signature: bytes,
|
||||||
|
data: bytes,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Verify a signature using a public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
public_key: Ed25519 public key
|
||||||
|
signature: 64-byte signature
|
||||||
|
data: Original signed data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
public_key.verify(signature, data)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
264
xml_pipeline/crypto/signing.py
Normal file
264
xml_pipeline/crypto/signing.py
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
"""
|
||||||
|
signing.py — Envelope signing and verification using Exclusive C14N + Ed25519.
|
||||||
|
|
||||||
|
Signing Process:
|
||||||
|
1. Remove any existing signature element from meta
|
||||||
|
2. Canonicalize the envelope using Exclusive C14N
|
||||||
|
3. Sign the canonical bytes with Ed25519
|
||||||
|
4. Insert signature element into meta block
|
||||||
|
|
||||||
|
Verification Process:
|
||||||
|
1. Extract and remove signature element from meta
|
||||||
|
2. Canonicalize the envelope (without signature)
|
||||||
|
3. Verify signature against canonical bytes
|
||||||
|
|
||||||
|
The signature element uses namespace: https://xml-pipeline.org/ns/sig/v1
|
||||||
|
This fits within the xs:any in envelope.xsd meta block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import copy
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
|
||||||
|
from xml_pipeline.crypto.identity import Identity
|
||||||
|
|
||||||
|
# Namespace for signature element
|
||||||
|
SIGNATURE_NAMESPACE = "https://xml-pipeline.org/ns/sig/v1"
|
||||||
|
SIGNATURE_TAG = f"{{{SIGNATURE_NAMESPACE}}}sig"
|
||||||
|
|
||||||
|
# Envelope namespace
|
||||||
|
ENVELOPE_NAMESPACE = "https://xml-pipeline.org/ns/envelope/v1"
|
||||||
|
META_TAG = f"{{{ENVELOPE_NAMESPACE}}}meta"
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureError(Exception):
|
||||||
|
"""Raised when signing or verification fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _find_meta(envelope: etree._Element) -> etree._Element | None:
|
||||||
|
"""Find the meta element in an envelope."""
|
||||||
|
# Try with namespace first
|
||||||
|
meta = envelope.find(f"{{{ENVELOPE_NAMESPACE}}}meta")
|
||||||
|
if meta is not None:
|
||||||
|
return meta
|
||||||
|
|
||||||
|
# Try without namespace (for flexibility)
|
||||||
|
meta = envelope.find("meta")
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_signature(envelope: etree._Element) -> etree._Element | None:
|
||||||
|
"""
|
||||||
|
Remove signature element from envelope's meta block.
|
||||||
|
|
||||||
|
Returns the removed signature element, or None if not found.
|
||||||
|
Modifies envelope in place.
|
||||||
|
"""
|
||||||
|
meta = _find_meta(envelope)
|
||||||
|
if meta is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Look for signature element
|
||||||
|
sig = meta.find(f"{{{SIGNATURE_NAMESPACE}}}sig")
|
||||||
|
if sig is not None:
|
||||||
|
meta.remove(sig)
|
||||||
|
return sig
|
||||||
|
|
||||||
|
# Try without namespace
|
||||||
|
sig = meta.find("sig")
|
||||||
|
if sig is not None:
|
||||||
|
meta.remove(sig)
|
||||||
|
return sig
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _canonicalize(tree: etree._Element) -> bytes:
|
||||||
|
"""
|
||||||
|
Canonicalize an XML element using Exclusive C14N.
|
||||||
|
|
||||||
|
This produces deterministic output suitable for signing.
|
||||||
|
"""
|
||||||
|
return etree.tostring(tree, method="c14n2", exclusive=True)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_envelope(
|
||||||
|
envelope: etree._Element,
|
||||||
|
identity: Identity,
|
||||||
|
*,
|
||||||
|
in_place: bool = False,
|
||||||
|
) -> etree._Element:
|
||||||
|
"""
|
||||||
|
Sign an envelope using the organism's identity.
|
||||||
|
|
||||||
|
The signature is inserted as a <sig> element in the meta block,
|
||||||
|
using namespace https://xml-pipeline.org/ns/sig/v1.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: The message envelope to sign
|
||||||
|
identity: Organism identity with private key
|
||||||
|
in_place: If True, modify envelope directly; if False, work on a copy
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Signed envelope (same object if in_place=True, copy otherwise)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SignatureError: If envelope structure is invalid
|
||||||
|
"""
|
||||||
|
if not in_place:
|
||||||
|
envelope = copy.deepcopy(envelope)
|
||||||
|
|
||||||
|
# Find meta block
|
||||||
|
meta = _find_meta(envelope)
|
||||||
|
if meta is None:
|
||||||
|
raise SignatureError("Envelope missing <meta> block")
|
||||||
|
|
||||||
|
# Remove any existing signature (for re-signing)
|
||||||
|
_remove_signature(envelope)
|
||||||
|
|
||||||
|
# Canonicalize the envelope (without signature)
|
||||||
|
canonical_bytes = _canonicalize(envelope)
|
||||||
|
|
||||||
|
# Sign the canonical bytes
|
||||||
|
signature_b64 = identity.sign_base64(canonical_bytes)
|
||||||
|
|
||||||
|
# Create signature element
|
||||||
|
sig_element = etree.SubElement(
|
||||||
|
meta,
|
||||||
|
SIGNATURE_TAG,
|
||||||
|
nsmap={"sig": SIGNATURE_NAMESPACE},
|
||||||
|
)
|
||||||
|
sig_element.text = signature_b64
|
||||||
|
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
|
||||||
|
def extract_signature(envelope: etree._Element) -> str | None:
|
||||||
|
"""
|
||||||
|
Extract the base64 signature from an envelope without modifying it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: The message envelope
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded signature string, or None if not signed
|
||||||
|
"""
|
||||||
|
meta = _find_meta(envelope)
|
||||||
|
if meta is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Look for signature element
|
||||||
|
sig = meta.find(f"{{{SIGNATURE_NAMESPACE}}}sig")
|
||||||
|
if sig is not None and sig.text:
|
||||||
|
return sig.text.strip()
|
||||||
|
|
||||||
|
# Try without namespace
|
||||||
|
sig = meta.find("sig")
|
||||||
|
if sig is not None and sig.text:
|
||||||
|
return sig.text.strip()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_envelope(
|
||||||
|
envelope: etree._Element,
|
||||||
|
public_key: Ed25519PublicKey,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Verify an envelope's signature using a public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: The signed message envelope
|
||||||
|
public_key: Ed25519 public key of the signer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if signature is valid, False otherwise
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Returns False for unsigned envelopes (no signature present).
|
||||||
|
To check if envelope is signed, use extract_signature() first.
|
||||||
|
"""
|
||||||
|
# Work on a copy to avoid modifying the original
|
||||||
|
envelope_copy = copy.deepcopy(envelope)
|
||||||
|
|
||||||
|
# Extract and remove signature
|
||||||
|
sig_element = _remove_signature(envelope_copy)
|
||||||
|
if sig_element is None or not sig_element.text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
signature_b64 = sig_element.text.strip()
|
||||||
|
signature = base64.b64decode(signature_b64)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Canonicalize the envelope (without signature)
|
||||||
|
canonical_bytes = _canonicalize(envelope_copy)
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
|
try:
|
||||||
|
public_key.verify(signature, canonical_bytes)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_envelope_with_identity(
|
||||||
|
envelope: etree._Element,
|
||||||
|
identity: Identity,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Verify an envelope using an Identity's public key.
|
||||||
|
|
||||||
|
Convenience wrapper around verify_envelope for local verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: The signed message envelope
|
||||||
|
identity: Identity containing public key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if signature is valid, False otherwise
|
||||||
|
"""
|
||||||
|
return verify_envelope(envelope, identity.public_key)
|
||||||
|
|
||||||
|
|
||||||
|
def is_signed(envelope: etree._Element) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an envelope has a signature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: Message envelope to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if envelope contains a signature element
|
||||||
|
"""
|
||||||
|
return extract_signature(envelope) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def strip_signature(envelope: etree._Element, *, in_place: bool = False) -> etree._Element:
|
||||||
|
"""
|
||||||
|
Remove signature from an envelope.
|
||||||
|
|
||||||
|
Useful for processing after verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
envelope: The signed envelope
|
||||||
|
in_place: If True, modify directly; if False, work on copy
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Envelope without signature
|
||||||
|
"""
|
||||||
|
if not in_place:
|
||||||
|
envelope = copy.deepcopy(envelope)
|
||||||
|
|
||||||
|
_remove_signature(envelope)
|
||||||
|
return envelope
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""
|
||||||
|
envelope.py — Envelope construction and manipulation utilities.
|
||||||
|
|
||||||
|
The universal envelope format:
|
||||||
|
<message xmlns="https://xml-pipeline.org/ns/envelope/v1">
|
||||||
|
<meta>
|
||||||
|
<from>sender</from>
|
||||||
|
<to>receiver</to>
|
||||||
|
<thread>uuid</thread>
|
||||||
|
<sig xmlns="https://xml-pipeline.org/ns/sig/v1">base64...</sig>
|
||||||
|
</meta>
|
||||||
|
<Payload>...</Payload>
|
||||||
|
</message>
|
||||||
|
|
||||||
|
This module provides utilities for:
|
||||||
|
- Building envelopes from payloads
|
||||||
|
- Extracting metadata from envelopes
|
||||||
|
- Signing and verification integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from xml_pipeline.crypto.identity import Identity
|
||||||
|
|
||||||
|
# Namespaces
|
||||||
|
ENVELOPE_NAMESPACE = "https://xml-pipeline.org/ns/envelope/v1"
|
||||||
|
ENVELOPE_NSMAP = {None: ENVELOPE_NAMESPACE}
|
||||||
|
|
||||||
|
|
||||||
|
def build_envelope(
|
||||||
|
payload: bytes | etree._Element,
|
||||||
|
from_id: str,
|
||||||
|
thread_id: str,
|
||||||
|
to_id: str | None = None,
|
||||||
|
identity: Identity | None = None,
|
||||||
|
) -> etree._Element:
|
||||||
|
"""
|
||||||
|
Build a message envelope around a payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: XML payload as bytes or element
|
||||||
|
from_id: Sender identifier (listener name)
|
||||||
|
thread_id: Thread UUID
|
||||||
|
to_id: Optional target listener name
|
||||||
|
identity: Optional identity for signing
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete message envelope element
|
||||||
|
"""
|
||||||
|
# Create message root
|
||||||
|
message = etree.Element(
|
||||||
|
f"{{{ENVELOPE_NAMESPACE}}}message",
|
||||||
|
nsmap=ENVELOPE_NSMAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create meta block
|
||||||
|
meta = etree.SubElement(message, f"{{{ENVELOPE_NAMESPACE}}}meta")
|
||||||
|
|
||||||
|
# Add required fields
|
||||||
|
from_elem = etree.SubElement(meta, f"{{{ENVELOPE_NAMESPACE}}}from")
|
||||||
|
from_elem.text = from_id
|
||||||
|
|
||||||
|
if to_id:
|
||||||
|
to_elem = etree.SubElement(meta, f"{{{ENVELOPE_NAMESPACE}}}to")
|
||||||
|
to_elem.text = to_id
|
||||||
|
|
||||||
|
thread_elem = etree.SubElement(meta, f"{{{ENVELOPE_NAMESPACE}}}thread")
|
||||||
|
thread_elem.text = thread_id
|
||||||
|
|
||||||
|
# Add payload
|
||||||
|
if isinstance(payload, bytes):
|
||||||
|
# Parse payload bytes
|
||||||
|
parser = etree.XMLParser(recover=True)
|
||||||
|
payload_elem = etree.fromstring(payload, parser=parser)
|
||||||
|
else:
|
||||||
|
payload_elem = payload
|
||||||
|
|
||||||
|
message.append(payload_elem)
|
||||||
|
|
||||||
|
# Sign if identity provided
|
||||||
|
if identity is not None:
|
||||||
|
from xml_pipeline.crypto.signing import sign_envelope
|
||||||
|
|
||||||
|
message = sign_envelope(message, identity, in_place=True)
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def envelope_to_bytes(envelope: etree._Element) -> bytes:
|
||||||
|
"""
|
||||||
|
Serialize an envelope to bytes.
|
||||||
|
|
||||||
|
Uses UTF-8 encoding with XML declaration.
|
||||||
|
"""
|
||||||
|
return etree.tostring(envelope, encoding="utf-8", xml_declaration=True)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_meta(envelope: etree._Element) -> dict[str, str | None]:
|
||||||
|
"""
|
||||||
|
Extract metadata from an envelope.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: from_id, to_id, thread_id
|
||||||
|
"""
|
||||||
|
result: dict[str, str | None] = {
|
||||||
|
"from_id": None,
|
||||||
|
"to_id": None,
|
||||||
|
"thread_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find meta element
|
||||||
|
meta = envelope.find(f"{{{ENVELOPE_NAMESPACE}}}meta")
|
||||||
|
if meta is None:
|
||||||
|
meta = envelope.find("meta")
|
||||||
|
|
||||||
|
if meta is None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Extract fields
|
||||||
|
from_elem = meta.find(f"{{{ENVELOPE_NAMESPACE}}}from")
|
||||||
|
if from_elem is None:
|
||||||
|
from_elem = meta.find("from")
|
||||||
|
if from_elem is not None and from_elem.text:
|
||||||
|
result["from_id"] = from_elem.text.strip()
|
||||||
|
|
||||||
|
to_elem = meta.find(f"{{{ENVELOPE_NAMESPACE}}}to")
|
||||||
|
if to_elem is None:
|
||||||
|
to_elem = meta.find("to")
|
||||||
|
if to_elem is not None and to_elem.text:
|
||||||
|
result["to_id"] = to_elem.text.strip()
|
||||||
|
|
||||||
|
thread_elem = meta.find(f"{{{ENVELOPE_NAMESPACE}}}thread")
|
||||||
|
if thread_elem is None:
|
||||||
|
thread_elem = meta.find("thread")
|
||||||
|
if thread_elem is not None and thread_elem.text:
|
||||||
|
result["thread_id"] = thread_elem.text.strip()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def extract_payload(envelope: etree._Element) -> etree._Element | None:
|
||||||
|
"""
|
||||||
|
Extract the payload element from an envelope.
|
||||||
|
|
||||||
|
The payload is the first non-meta child of the message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Payload element, or None if not found
|
||||||
|
"""
|
||||||
|
for child in envelope:
|
||||||
|
# Skip meta element
|
||||||
|
tag = child.tag
|
||||||
|
if isinstance(tag, str):
|
||||||
|
if tag.endswith("}meta") or tag == "meta":
|
||||||
|
continue
|
||||||
|
return child
|
||||||
|
return None
|
||||||
|
|
@ -26,7 +26,10 @@ import logging
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AsyncIterable, Callable, List, Dict, Any, Optional
|
from typing import AsyncIterable, Callable, List, Dict, Any, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from xml_pipeline.crypto.identity import Identity
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
@ -210,6 +213,16 @@ class StreamPump:
|
||||||
self.routing_table: Dict[str, List[Listener]] = {}
|
self.routing_table: Dict[str, List[Listener]] = {}
|
||||||
self.listeners: Dict[str, Listener] = {}
|
self.listeners: Dict[str, Listener] = {}
|
||||||
|
|
||||||
|
# Identity for envelope signing (optional)
|
||||||
|
self.identity: Optional["Identity"] = None
|
||||||
|
if config.identity_path:
|
||||||
|
try:
|
||||||
|
from xml_pipeline.crypto import Identity
|
||||||
|
self.identity = Identity.load(config.identity_path)
|
||||||
|
pump_logger.info(f"Identity loaded: {config.identity_path}")
|
||||||
|
except Exception as e:
|
||||||
|
pump_logger.warning(f"Failed to load identity: {e}")
|
||||||
|
|
||||||
# Generic listeners (accept any payload type)
|
# Generic listeners (accept any payload type)
|
||||||
# Used for ephemeral orchestration handlers (sequences, buffers)
|
# Used for ephemeral orchestration handlers (sequences, buffers)
|
||||||
self._generic_listeners: Dict[str, Listener] = {}
|
self._generic_listeners: Dict[str, Listener] = {}
|
||||||
|
|
@ -660,7 +673,7 @@ class StreamPump:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _wrap_in_envelope(self, payload: Any, from_id: str, to_id: str, thread_id: str) -> bytes:
|
def _wrap_in_envelope(self, payload: Any, from_id: str, to_id: str, thread_id: str) -> bytes:
|
||||||
"""Wrap a dataclass payload in a message envelope."""
|
"""Wrap a dataclass payload in a message envelope, optionally signed."""
|
||||||
# Serialize payload to XML
|
# Serialize payload to XML
|
||||||
if hasattr(payload, 'to_xml'):
|
if hasattr(payload, 'to_xml'):
|
||||||
# SystemError and similar have manual to_xml()
|
# SystemError and similar have manual to_xml()
|
||||||
|
|
@ -680,7 +693,7 @@ class StreamPump:
|
||||||
idx = payload_str.index('>')
|
idx = payload_str.index('>')
|
||||||
payload_str = payload_str[:idx] + ' xmlns=""' + payload_str[idx:]
|
payload_str = payload_str[:idx] + ' xmlns=""' + payload_str[idx:]
|
||||||
|
|
||||||
envelope = f"""<message xmlns="https://xml-pipeline.org/ns/envelope/v1">
|
envelope_str = f"""<message xmlns="https://xml-pipeline.org/ns/envelope/v1">
|
||||||
<meta>
|
<meta>
|
||||||
<from>{from_id}</from>
|
<from>{from_id}</from>
|
||||||
<to>{to_id}</to>
|
<to>{to_id}</to>
|
||||||
|
|
@ -688,7 +701,19 @@ class StreamPump:
|
||||||
</meta>
|
</meta>
|
||||||
{payload_str}
|
{payload_str}
|
||||||
</message>"""
|
</message>"""
|
||||||
return envelope.encode('utf-8')
|
|
||||||
|
# Sign if identity is configured
|
||||||
|
if self.identity is not None:
|
||||||
|
try:
|
||||||
|
from xml_pipeline.crypto.signing import sign_envelope
|
||||||
|
envelope_tree = etree.fromstring(envelope_str.encode('utf-8'))
|
||||||
|
signed_tree = sign_envelope(envelope_tree, self.identity, in_place=True)
|
||||||
|
return etree.tostring(signed_tree, encoding='utf-8', xml_declaration=True)
|
||||||
|
except Exception as e:
|
||||||
|
pump_logger.warning(f"Failed to sign envelope: {e}")
|
||||||
|
# Fall through to unsigned
|
||||||
|
|
||||||
|
return envelope_str.encode('utf-8')
|
||||||
|
|
||||||
async def _dispatch_to_process_pool(
|
async def _dispatch_to_process_pool(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue