6270d8c956
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m3s
Testing / test (push) Failing after 1m15s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m6s
- Add SSH-style server identity keys and known-host verification for remote serve endpoints. - Add remote add/list/remove commands for explicit endpoint persistence. - Fix remote clients listing to fan out through target discovery instead of ambiguous auto-routing. - Add URL glob matching for tabs filter and count with extension tests. - Add n8n credential pinning for server public keys or SHA256 fingerprints. - Remove obsolete compat shim behavior while keeping empty compat seams for future protocol changes. - Bump browser-cli to 0.16.4 and n8n node to 0.3.1. - Cover known-hosts, remote registry, compat seams, n8n protocol verification, and URL matching with tests.
58 lines
2.2 KiB
Python
58 lines
2.2 KiB
Python
"""Persistent server identity keys for SSH-style remote host pinning."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key
|
|
|
|
from browser_cli.constants import CONFIG_DIR
|
|
|
|
SERVER_IDENTITY_PATH = CONFIG_DIR / "server_identity.pem"
|
|
|
|
def load_or_create_server_identity(path: Path = SERVER_IDENTITY_PATH) -> Ed25519PrivateKey:
|
|
"""Load the persistent serve identity key, creating it on first start."""
|
|
if path.exists():
|
|
key = load_pem_private_key(path.read_bytes(), password=None)
|
|
if not isinstance(key, Ed25519PrivateKey):
|
|
raise ValueError(f"server identity key is not Ed25519: {path}")
|
|
return key
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
key = Ed25519PrivateKey.generate()
|
|
pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
|
fd = path.open("xb")
|
|
try:
|
|
fd.write(pem)
|
|
finally:
|
|
fd.close()
|
|
path.chmod(0o600)
|
|
return key
|
|
|
|
def public_key_hex(key: Ed25519PrivateKey) -> str:
|
|
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
|
|
|
def _signed_challenge_fields(challenge: dict) -> dict:
|
|
return {key: value for key, value in challenge.items() if key != "server_sig"}
|
|
|
|
def challenge_payload(challenge: dict) -> bytes:
|
|
"""Canonical bytes signed by the server identity key."""
|
|
return json.dumps(_signed_challenge_fields(challenge), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
|
|
def sign_challenge(challenge: dict, key: Ed25519PrivateKey) -> str:
|
|
return key.sign(challenge_payload(challenge)).hex()
|
|
|
|
def verify_challenge_signature(challenge: dict) -> bool:
|
|
pub_hex = challenge.get("server_pubkey")
|
|
sig_hex = challenge.get("server_sig")
|
|
if not isinstance(pub_hex, str) or not isinstance(sig_hex, str):
|
|
return False
|
|
try:
|
|
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex))
|
|
pub.verify(bytes.fromhex(sig_hex), challenge_payload(challenge))
|
|
return True
|
|
except (InvalidSignature, ValueError):
|
|
return False
|