Files
Juan DenisandClaude Opus 4.8 f295103f29 fix(pairing): verify Ed25519 proof-of-possession on pairing poll
During short-code pairing the agent submitted its Ed25519 public key at
enroll time, but the panel never checked that the poller actually held the
matching private key -- identity rested entirely on the bearer
enrollment_secret. Anyone who captured that secret (e.g. from a log or proxy)
could claim the device's credentials.

The agent now signs the enrollment_id with its pairing keypair and sends the
base64 signature as X-Enrollment-Signature on each poll. pairing_service.poll
verifies it against the enrolled hex pubkey (cryptography Ed25519) and rejects
a present-but-invalid or wrong-key signature with InvalidEnrollmentError,
before touching any state. So a stolen enrollment_secret alone no longer
suffices -- the attacker also needs the private key.

Verification is best-effort for backward compatibility: a poll with no
signature still works (older agents), and becomes mandatory once signing
agents have rolled out (tracked in SECURITY.md).

Agent side: KeyPair.Sign(); pairing.Client gains an optional keypair via
SetKeyPair(), wired in at both call sites (cmd/agent/pair.go and
internal/pairdriver/driver.go); Poll attaches the signature header.

Verified: backend 89 passed / 12 skipped (4 new PoP tests: valid sig accepted,
invalid rejected, wrong-key rejected, no-sig backward-compatible); go build
./... + go vet + go test ./... all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:08:32 -04:00

68 lines
2.4 KiB
Python

"""
Tests for Ed25519 proof-of-possession in the short-code pairing poll (bug D).
The agent enrolls a hex-encoded Ed25519 pubkey, then signs the enrollment_id
with the matching private key on each poll. The panel verifies that signature
when present, so a stolen enrollment_secret alone can't claim a device whose
private key the attacker lacks. Verification is best-effort for backward
compatibility: a poll with no signature still works (older agents), but a
present-but-invalid signature is rejected.
"""
import base64
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from app.services import pairing_service
from app.services.pairing_service import InvalidEnrollmentError
def _enroll():
"""Enroll a fresh keypair; return (private_key, enrollment dict)."""
priv = Ed25519PrivateKey.generate()
raw_pub = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
pubkey_hex = raw_pub.hex()
enr = pairing_service.enroll(pubkey_hex, "pass1234", "machine-pop", {})
return priv, enr
def _sig(priv, enrollment_id):
return base64.b64encode(priv.sign(enrollment_id.encode())).decode()
def test_poll_accepts_valid_signature(app):
priv, enr = _enroll()
eid, esec = enr['enrollment_id'], enr['enrollment_secret']
res = pairing_service.poll(eid, esec, signature=_sig(priv, eid))
# Not claimed yet, but the signature was accepted (no exception raised).
assert res['status'] == 'pending'
def test_poll_rejects_invalid_signature(app):
priv, enr = _enroll()
eid, esec = enr['enrollment_id'], enr['enrollment_secret']
bad_sig = base64.b64encode(b'\x00' * 64).decode()
with pytest.raises(InvalidEnrollmentError):
pairing_service.poll(eid, esec, signature=bad_sig)
def test_poll_rejects_signature_from_wrong_key(app):
_priv, enr = _enroll()
eid, esec = enr['enrollment_id'], enr['enrollment_secret']
attacker = Ed25519PrivateKey.generate()
wrong_sig = base64.b64encode(attacker.sign(eid.encode())).decode()
with pytest.raises(InvalidEnrollmentError):
pairing_service.poll(eid, esec, signature=wrong_sig)
def test_poll_without_signature_is_backward_compatible(app):
_priv, enr = _enroll()
eid, esec = enr['enrollment_id'], enr['enrollment_secret']
res = pairing_service.poll(eid, esec)
assert res['status'] == 'pending'