Files
bal-server/contrib/make_onion_delegation.py
svatantrya 63f65f930a feat(pusher): add validated onion delegation for v2 pings
- Load WELIST_PING_DELEGATION_FILE into a typed OnionDelegation
- Send the certificate only with v2 pings for onion hosts
- Validate the onion host, expiry and delegated key against
  BAL_SERVER_URL and SSL_KEY_PATH at startup; disable delegation
  with a warning on any mismatch
- Stop sending unsigned info/base_fee/version with v1 pings so
  v1 remains liveness-only
- Document v1/v2 metadata handling and onion delegation implications
- Add contrib/make_onion_delegation.py to issue certificates offline,
  with Ed25519 key validation and clean CLI error handling
2026-11-08 23:07:08 -04:00

215 lines
8.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Issue an onion delegation certificate for v2 WeTime ping signing.
The onion v3 identity key signs a certificate that binds a dedicated Ed25519
ping key to the onion host. The online pusher then signs pings with the
dedicated key, so the onion identity key can stay offline.
The certificate covers these exact bytes:
WELIST-PING-DELEGATION-V1\n
{"key":"<base64 SPKI DER>","onion":"<host>","valid_until":<unix>}
Usage:
contrib/make_onion_delegation.py \
--onion <address>.onion \
--identity-key /var/lib/tor/hidden_service/hs_ed25519_secret_key \
--ping-key /etc/bal/ping_private_key.pem \
--days 365 \
--out onion-delegation.json
`--identity-key` accepts either:
* a Tor `hs_ed25519_secret_key` file (32-byte header + secret key), or
* a standard PKCS#8 PEM Ed25519 private key.
Requirements: Python 3.10+, `cryptography`, and `pynacl` for Tor-format keys.
"""
import argparse
import base64
import hashlib
import json
import sys
import time
from pathlib import Path
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
except ImportError: # pragma: no cover - dependency hint
print("error: the 'cryptography' package is required", file=sys.stderr)
sys.exit(2)
DELEGATION_PREFIX = b"WELIST-PING-DELEGATION-V1\n"
TOR_HEADER_LEN = 32
def b32_decode(value: str) -> bytes:
"""Decode lowercase base32 without padding."""
padding = "=" * ((8 - len(value) % 8) % 8)
return base64.b32decode(value.upper() + padding)
def decode_onion(host: str) -> bytes:
"""Return the 32-byte Ed25519 identity public key of a v3 onion host."""
host = host.strip().lower().rstrip(".")
if not host.endswith(".onion"):
raise ValueError("not an onion address")
address = host[: -len(".onion")]
if len(address) != 56:
raise ValueError("unsupported onion address length")
if not all(c in "abcdefghijklmnopqrstuvwxyz234567" for c in address):
raise ValueError("invalid onion base32")
decoded = b32_decode(address)
if len(decoded) != 35:
raise ValueError("invalid onion payload length")
public_key, checksum, version = decoded[:32], decoded[32:34], decoded[34]
if version != 3:
raise ValueError("unsupported onion version")
expected = hashlib.sha3_256(
b".onion checksum" + public_key + bytes([version])
).digest()[:2]
if checksum != expected:
raise ValueError("onion checksum mismatch")
return public_key
def load_ping_key(ping_key_path: Path) -> bytes:
"""Return the delegated ping public key as base64 SPKI DER."""
key = serialization.load_pem_private_key(ping_key_path.read_bytes(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("--ping-key must be an Ed25519 private key")
return base64.b64encode(
key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
).decode("ascii")
def sign_standard(key: Ed25519PrivateKey, message: bytes) -> bytes:
"""Sign with a standard Ed25519 private key."""
return key.sign(message)
def sign_expanded(secret: bytes, public_key: bytes, message: bytes) -> bytes:
"""Sign with a Tor expanded Ed25519 secret key (a || prefix)."""
try:
from nacl.bindings import (
crypto_core_ed25519_scalar_add,
crypto_core_ed25519_scalar_mul,
crypto_core_ed25519_scalar_reduce,
crypto_scalarmult_ed25519_base_noclamp,
)
except ImportError as exc: # pragma: no cover - dependency hint
raise ValueError(
"pynacl is required to sign with a Tor-format onion key"
) from exc
scalar, prefix = secret[:32], secret[32:64]
try:
if crypto_scalarmult_ed25519_base_noclamp(scalar) != public_key:
raise ValueError("onion secret key does not match the onion address")
nonce = crypto_core_ed25519_scalar_reduce(
hashlib.sha512(prefix + message).digest()
)
point = crypto_scalarmult_ed25519_base_noclamp(nonce)
challenge = crypto_core_ed25519_scalar_reduce(
hashlib.sha512(point + public_key + message).digest()
)
response = crypto_core_ed25519_scalar_add(
nonce, crypto_core_ed25519_scalar_mul(challenge, scalar)
)
return point + response
except ValueError:
raise
except Exception as exc: # PyNaCl raises RuntimeError for invalid scalars
raise ValueError(f"cannot sign with onion identity key: {exc}") from exc
def load_identity_signer(identity_key_path: Path, public_key: bytes):
"""Return a callable that signs with the onion identity key."""
raw = identity_key_path.read_bytes()
if raw.lstrip().startswith(b"-----BEGIN"):
key = serialization.load_pem_private_key(raw, password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("identity PEM key is not Ed25519")
derived = key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
if derived != public_key:
raise ValueError("identity key does not match the onion address")
return lambda message: sign_standard(key, message)
if len(raw) < TOR_HEADER_LEN + 64:
raise ValueError("Tor secret key file is too short")
secret = raw[TOR_HEADER_LEN : TOR_HEADER_LEN + 64]
# Tor stores the expanded secret key (scalar || prefix). Some tools export
# a standard 32-byte seed instead, so accept that form too.
try:
seed_key = Ed25519PrivateKey.from_private_bytes(secret[:32])
derived = seed_key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
except Exception:
seed_key = None
derived = b""
if seed_key is not None and derived == public_key:
return lambda message: sign_standard(seed_key, message)
return lambda message: sign_expanded(secret, public_key, message)
def canonical_message(key_b64: str, onion: str, valid_until: int) -> bytes:
"""Build the exact certificate bytes signed by the identity key."""
body = {"key": key_b64, "onion": onion, "valid_until": valid_until}
encoded = json.dumps(
body, separators=(",", ":"), sort_keys=True, ensure_ascii=False
).encode("utf-8")
return DELEGATION_PREFIX + encoded
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--onion", required=True, help="onion host (address.onion)")
parser.add_argument("--identity-key", required=True,
help="Tor hs_ed25519_secret_key or PKCS#8 PEM")
parser.add_argument("--ping-key", required=True,
help="PKCS#8 PEM private key of the delegated ping signer")
parser.add_argument("--days", type=int, default=365,
help="certificate lifetime in days (default: 365)")
parser.add_argument("--out", default="onion-delegation.json",
help="output JSON path (default: onion-delegation.json)")
args = parser.parse_args()
if args.days <= 0 or args.days > 3650:
print("error: --days must be between 1 and 3650", file=sys.stderr)
return 2
onion = args.onion.strip().lower().rstrip(".")
public_key = decode_onion(onion)
key_b64 = load_ping_key(Path(args.ping_key))
valid_until = int(time.time()) + args.days * 86_400
message = canonical_message(key_b64, onion, valid_until)
signer = load_identity_signer(Path(args.identity_key), public_key)
certificate = {
"onion": onion,
"key": key_b64,
"valid_until": valid_until,
"signature": base64.b64encode(signer(message)).decode("ascii"),
}
Path(args.out).write_text(json.dumps(certificate, indent=2) + "\n")
print(f"wrote {args.out} (onion={onion}, valid_until={valid_until})")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (OSError, TypeError, ValueError) as err:
print(f"error: {err}", file=sys.stderr)
sys.exit(1)