blob: de05277e476336a0990af9861b9e916d952598b9 [file] [edit]
"""Integration tests for patatt sign/verify within b4.
Uses ephemeral ed25519 keys so no external key material is needed.
"""
import base64
import email.message
import os
import tempfile
from collections.abc import Generator
from typing import Tuple, Union
import pytest
from nacl.signing import SigningKey
import b4
import patatt
@pytest.fixture()
def ed25519_keypair() -> Generator[Tuple[str, str, str, str], None, None]:
"""Generate an ephemeral ed25519 keypair written to temp files.
Returns (privkey_path, verify_key_b64, identity, selector).
The private key file is written so patatt can find it via
signingkey = ed25519:/path/to/key.
"""
sk = SigningKey.generate()
sk_b64 = base64.b64encode(sk.encode()).decode()
vk_b64 = base64.b64encode(sk.verify_key.encode()).decode()
with tempfile.NamedTemporaryFile(mode='w', suffix='.key', delete=False) as fh:
fh.write(sk_b64)
privkey_path = fh.name
yield privkey_path, vk_b64, 'test@example.com', 'default'
os.unlink(privkey_path)
@pytest.fixture()
def keyring_dir(
ed25519_keypair: Tuple[str, str, str, str],
) -> Generator[str, None, None]:
"""Create a temporary keyring directory with the ephemeral public key.
The directory layout follows patatt's expected structure:
<keyring>/<identity>/ed25519/<selector>
"""
_privkey, vk_b64, identity, selector = ed25519_keypair
# patatt looks up keys as: ed25519/<domain>/<localpart>/<selector>
local, domain = identity.split('@', 1)
with tempfile.TemporaryDirectory() as tmpdir:
key_dir = os.path.join(tmpdir, 'ed25519', domain, local)
os.makedirs(key_dir)
key_file = os.path.join(key_dir, selector)
with open(key_file, 'w') as fh:
fh.write(vk_b64)
yield tmpdir
def _make_test_message(
from_addr: str = 'test@example.com',
subject: str = 'Test patch',
body: str = 'This is a test.\n',
) -> bytes:
"""Build a minimal RFC2822 message as bytes."""
msg = email.message.EmailMessage()
msg['From'] = from_addr
msg['To'] = 'list@example.com'
msg['Subject'] = subject
msg['Message-ID'] = '<test-001@example.com>'
msg.set_payload(body, charset='utf-8')
return msg.as_bytes(policy=b4.emlpolicy)
class TestPatattSignVerify:
"""Round-trip sign and verify using ephemeral ed25519 keys."""
def test_sign_and_verify(
self, ed25519_keypair: Tuple[str, str, str, str], keyring_dir: str
) -> None:
"""A signed message should validate with the matching public key."""
privkey_path, _vk_b64, identity, selector = ed25519_keypair
msg_bytes = _make_test_message(from_addr=identity)
config: dict[str, Union[str, list[str]]] = {
'identity': identity,
'selector': selector,
'signingkey': f'ed25519:{privkey_path}',
}
signed = patatt.rfc2822_sign(msg_bytes, config=config)
assert b'X-Developer-Signature' in signed
results = patatt.validate_message(signed, [keyring_dir])
assert len(results) > 0
assert results[0][0] == patatt.RES_VALID
def test_tampered_body_fails(
self, ed25519_keypair: Tuple[str, str, str, str], keyring_dir: str
) -> None:
"""Modifying the body after signing should fail validation."""
privkey_path, _vk_b64, identity, selector = ed25519_keypair
msg_bytes = _make_test_message(from_addr=identity)
config: dict[str, Union[str, list[str]]] = {
'identity': identity,
'selector': selector,
'signingkey': f'ed25519:{privkey_path}',
}
signed = patatt.rfc2822_sign(msg_bytes, config=config)
# Tamper with the body
tampered = signed.replace(b'This is a test.', b'This is TAMPERED.')
results = patatt.validate_message(tampered, [keyring_dir])
assert len(results) > 0
assert results[0][0] == patatt.RES_BADSIG
def test_wrong_key_fails(self, ed25519_keypair: Tuple[str, str, str, str]) -> None:
"""Validating against a different public key should fail."""
privkey_path, _vk_b64, identity, selector = ed25519_keypair
msg_bytes = _make_test_message(from_addr=identity)
config: dict[str, Union[str, list[str]]] = {
'identity': identity,
'selector': selector,
'signingkey': f'ed25519:{privkey_path}',
}
signed = patatt.rfc2822_sign(msg_bytes, config=config)
# Create a keyring with a different key
other_sk = SigningKey.generate()
other_vk_b64 = base64.b64encode(other_sk.verify_key.encode()).decode()
local, domain = identity.split('@', 1)
with tempfile.TemporaryDirectory() as tmpdir:
key_dir = os.path.join(tmpdir, 'ed25519', domain, local)
os.makedirs(key_dir)
with open(os.path.join(key_dir, selector), 'w') as fh:
fh.write(other_vk_b64)
results = patatt.validate_message(signed, [tmpdir])
assert len(results) > 0
assert results[0][0] == patatt.RES_BADSIG
def test_no_key_available(self, ed25519_keypair: Tuple[str, str, str, str]) -> None:
"""Validating with an empty keyring should return RES_NOKEY."""
privkey_path, _vk_b64, identity, selector = ed25519_keypair
msg_bytes = _make_test_message(from_addr=identity)
config: dict[str, Union[str, list[str]]] = {
'identity': identity,
'selector': selector,
'signingkey': f'ed25519:{privkey_path}',
}
signed = patatt.rfc2822_sign(msg_bytes, config=config)
with tempfile.TemporaryDirectory() as empty_keyring:
results = patatt.validate_message(signed, [empty_keyring])
assert len(results) > 0
assert results[0][0] == patatt.RES_NOKEY
def test_unsigned_message(self, keyring_dir: str) -> None:
"""An unsigned message should return RES_NOSIG."""
msg_bytes = _make_test_message()
results = patatt.validate_message(msg_bytes, [keyring_dir])
assert len(results) == 1
assert results[0][0] == patatt.RES_NOSIG
def test_sign_adds_developer_key_header(
self, ed25519_keypair: Tuple[str, str, str, str]
) -> None:
"""Signing adds both X-Developer-Signature and X-Developer-Key."""
privkey_path, _vk_b64, identity, selector = ed25519_keypair
msg_bytes = _make_test_message(from_addr=identity)
config: dict[str, Union[str, list[str]]] = {
'identity': identity,
'selector': selector,
'signingkey': f'ed25519:{privkey_path}',
}
signed = patatt.rfc2822_sign(msg_bytes, config=config)
assert b'X-Developer-Signature' in signed
assert b'X-Developer-Key' in signed
assert b'a=ed25519' in signed
assert identity.encode() in signed