mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 18:17:11 +09:00
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
197 lines
7.5 KiB
Python
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())
|