Files
Juan DenisandClaude Opus 5 fd64ec09ba feat(connect): open commands sealed to this server's own key
A second keypair, X25519, published in the signed hello with an Ed25519
signature over it so ServerKit Cloud can tell it is really ours — and so a
relay cannot substitute one of its own, because it does not have this
panel's identity key.

Command arguments then arrive encrypted to that key. The relay carries a
blob. That is what makes it safe for Cloud to send an object-storage
credential through it, which until now crossed the relay in the clear.

A payload this panel cannot open is a refused command with a sentence
saying what to do, never a handler running on arguments we guessed at.

- backend/app/services/connect_keys.py — the key (a raw 32-byte file, not
  PEM: it is a key with no certificate story around it), the signed hello
  fields, and open_sealed.
- backend/app/services/connect_client.py — publishes it on WebSocket and
  on the long-poll fallback, so limited mode is not quietly less private.
- backend/app/services/connect_commands.py — unseal before the handler.
- backend/tests/test_connect_sealing.py — 13 tests, including the control plane's
  half of the scheme written independently from the specification, so the two
  implementations cannot drift apart silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
2026-09-02 17:00:00 -04:00

197 lines
7.5 KiB
Python

"""Device keypair for ServerKit Cloud connect pairing.
The panel holds one Ed25519 keypair that identifies it as a device to the
ServerKit Cloud control plane. The private key lives next to the panel config
(``paths.SERVERKIT_CONFIG_DIR``) with mode 0600, mirroring how the agent
stores its pairing key (``/etc/serverkit-agent/pairing.key``).
Fingerprint contract (verified server-side by ServerKit Cloud): the first 16 hex chars
of sha256 over the RAW 32-byte public key, displayed in groups of four
(``a1b2 c3d4 e5f6 7890``).
"""
import hashlib
import logging
import os
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from app import paths
logger = logging.getLogger(__name__)
KEY_FILENAME = 'connect_device_key.pem'
# The second keypair: X25519, used only to unseal command
# payloads. Separate from the identity key on purpose — an encryption key and
# a signing key have different lifetimes and different blast radii.
E2E_KEY_FILENAME = 'connect_e2e_key.bin'
# What the identity key signs so ServerKit Cloud knows this X25519 key is really ours.
E2E_BINDING = 'x25519'
def default_key_path() -> str:
return os.path.join(paths.SERVERKIT_CONFIG_DIR, KEY_FILENAME)
def fingerprint_of(pubkey_bytes: bytes) -> str:
"""First 16 hex chars of sha256(raw pubkey bytes) — ServerKit Cloud's contract."""
return hashlib.sha256(pubkey_bytes).hexdigest()[:16]
def format_fingerprint(fingerprint: str) -> str:
"""``a1b2c3d4e5f67890`` -> ``a1b2 c3d4 e5f6 7890`` (the agent's format)."""
return ' '.join(fingerprint[i:i + 4] for i in range(0, len(fingerprint), 4))
def _public_bytes(private_key: Ed25519PrivateKey) -> bytes:
return private_key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw,
)
def _load_existing(path: str) -> Ed25519PrivateKey:
with open(path, 'rb') as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
if not isinstance(private_key, Ed25519PrivateKey):
raise ValueError(f'{path} is not an Ed25519 private key')
if os.name != 'nt':
os.chmod(path, 0o600)
return private_key
def load_keypair(path: str = None):
"""Strictly load the device keypair; raises FileNotFoundError if absent.
Returns (private_key, pubkey_hex, fingerprint). Used by the relay
transport: generating a fresh key here would silently change the device
identity and fail every hello signature.
"""
path = path or default_key_path()
private_key = _load_existing(path)
pubkey_bytes = _public_bytes(private_key)
return private_key, pubkey_bytes.hex(), fingerprint_of(pubkey_bytes)
def load_or_create_keypair(path: str = None):
"""Load the device keypair from ``path``, generating + persisting one if absent.
Returns (private_key, pubkey_hex, fingerprint). The file is written with
mode 0600; on POSIX an existing file with looser permissions is tightened.
"""
path = path or default_key_path()
if os.path.exists(path):
private_key = _load_existing(path)
else:
private_key = Ed25519PrivateKey.generate()
os.makedirs(os.path.dirname(path), exist_ok=True)
pem = private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
# O_EXCL so a lost race regenerates instead of clobbering a live key.
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, 'wb') as f:
f.write(pem)
except Exception:
try:
os.unlink(path)
except OSError:
pass
raise
if os.name != 'nt':
os.chmod(path, 0o600)
logger.info('Generated ServerKit Cloud device key at %s', path)
pubkey_bytes = _public_bytes(private_key)
return private_key, pubkey_bytes.hex(), fingerprint_of(pubkey_bytes)
# ==================== end-to-end key ====================
def default_e2e_key_path() -> str:
return os.path.join(paths.SERVERKIT_CONFIG_DIR, E2E_KEY_FILENAME)
def load_or_create_e2e_key(path: str = None):
"""The X25519 private key this panel unseals commands with.
Raw 32 bytes on disk at 0600, not PEM: it is a symmetric-shaped secret
with no certificate story around it, and a file that is exactly the key is
a file nobody misreads.
Returns (private_key, pubkey_hex).
"""
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
path = path or default_e2e_key_path()
if os.path.exists(path):
with open(path, 'rb') as f:
raw = f.read()
if len(raw) != 32:
raise ValueError(f'{path} is not a 32-byte X25519 private key')
private_key = X25519PrivateKey.from_private_bytes(raw)
if os.name != 'nt':
os.chmod(path, 0o600)
else:
private_key = X25519PrivateKey.generate()
os.makedirs(os.path.dirname(path), exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, 'wb') as f:
f.write(private_key.private_bytes_raw())
except Exception:
try:
os.unlink(path)
except OSError:
pass
raise
if os.name != 'nt':
os.chmod(path, 0o600)
logger.info('Generated ServerKit Cloud end-to-end key at %s', path)
return private_key, private_key.public_key().public_bytes_raw().hex()
def e2e_binding_message(device_id: str, pubkey_hex: str) -> bytes:
"""The bytes the identity key signs to publish an X25519 key. Must match
the control plane's binding message exactly, or it will not seal."""
return f'{device_id}|{E2E_BINDING}|{pubkey_hex}'.encode()
def e2e_hello_fields(identity_key, device_id: str, path: str = None) -> dict:
"""The two hello fields that publish this panel's end-to-end key.
Signed with the identity key, so a relay that wanted to read command
payloads cannot substitute a key of its own — it does not have this
signature and cannot make one.
"""
_private, pubkey_hex = load_or_create_e2e_key(path)
sig = identity_key.sign(e2e_binding_message(device_id, pubkey_hex)).hex()
return {'x25519_pubkey': pubkey_hex, 'x25519_sig': sig}
def open_sealed(device_id: str, sealed: dict, path: str = None) -> dict:
"""Unseal a command payload. Raises if the algorithm is not one we know:
a command we cannot read is refused, never guessed at."""
import base64
import json
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
alg = (sealed or {}).get('alg')
if alg != 'x25519-hkdf-aesgcm/v1':
raise ValueError(f'unknown sealing algorithm {alg!r}')
private_key, _ = load_or_create_e2e_key(path)
shared = private_key.exchange(
X25519PublicKey.from_public_bytes(bytes.fromhex(sealed['epk'])))
key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None,
info=b'serverkit/cmd/v1|' + device_id.encode()).derive(shared)
plaintext = AESGCM(key).decrypt(base64.b64decode(sealed['nonce']),
base64.b64decode(sealed['ct']), device_id.encode())
return json.loads(plaintext.decode())