mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
3529 invalid comments in 528 files, grandfathered in while the linter only saw the working-tree diff. Removal ran through the linter's own collectors, so detection and deletion cannot drift: a comment whose opener line is blank before it and whose closer line is blank after it loses all its lines, anything else loses only the comment region, and a line left holding a bare delimiter goes with it. Five files keep their comments behind a nocheck marker instead. Four are upstream configs whose inline docs are upstream's own (mautrix, systemd journald, nextcloud-oidc-login, MediaWiki). In littlejs the commented-out entries ARE the deactivation mechanism for an example tile, so deleting them would delete configuration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
110 lines
3.7 KiB
Python
110 lines
3.7 KiB
Python
"""Offline Tor v3 onion-service key minting.
|
|
|
|
Deterministically derives a Tor v3 hidden-service address and the exact
|
|
``HiddenServiceDir`` key files from an ed25519 seed, without running Tor.
|
|
|
|
The v3 address is ``base32(PUBKEY || CHECKSUM || VERSION)`` where
|
|
``CHECKSUM = SHA3-256(".onion checksum" || PUBKEY || VERSION)[:2]`` and
|
|
``VERSION = 0x03`` (see spec.torproject.org/address-spec). Tor stores the
|
|
*expanded* ed25519 secret key ``clamp(SHA512(seed)[:32]) || SHA512(seed)[32:]``
|
|
in ``hs_ed25519_secret_key``; the public key ``clamp(SHA512(seed)[:32]) * B``
|
|
equals the standard ed25519 public key, so it is taken straight from
|
|
``cryptography`` without needing scalar multiplication.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
|
ONION_VERSION = b"\x03"
|
|
CHECKSUM_SALT = b".onion checksum"
|
|
|
|
IDENTITY_DIRNAME = ".onion-identity"
|
|
|
|
|
|
def identity_hs_dir(base_dir: str | Path) -> Path:
|
|
"""Authoritative hidden-service key dir (``<base_dir>/.onion-identity/hs``).
|
|
|
|
Single source of truth for the node's onion identity: ``ensure_node_onion``
|
|
mints/reuses the key files here and the ``svc-net-tor`` role copies them into
|
|
the running daemon. Lives outside anything the deploy regenerates and is
|
|
mounted into the DiD at ``{playbook_dir}/.onion-identity/hs``.
|
|
"""
|
|
return Path(base_dir) / IDENTITY_DIRNAME / "hs"
|
|
|
|
|
|
SECRET_KEY_HEADER = b"== ed25519v1-secret: type0 ==\x00\x00\x00"
|
|
PUBLIC_KEY_HEADER = b"== ed25519v1-public: type0 ==\x00\x00\x00"
|
|
|
|
SEED_LENGTH = 32
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OnionKey:
|
|
"""A minted Tor v3 onion key and its ``HiddenServiceDir`` file contents."""
|
|
|
|
seed: bytes
|
|
address: str
|
|
hostname: bytes
|
|
public_key: bytes
|
|
secret_key: bytes
|
|
|
|
def files(self) -> dict[str, bytes]:
|
|
"""Map ``HiddenServiceDir`` filenames to their exact byte contents."""
|
|
return {
|
|
"hostname": self.hostname,
|
|
"hs_ed25519_public_key": self.public_key,
|
|
"hs_ed25519_secret_key": self.secret_key,
|
|
}
|
|
|
|
|
|
def onion_address(public_key: bytes) -> str:
|
|
"""Return the v3 ``.onion`` address for a 32-byte ed25519 public key."""
|
|
if len(public_key) != 32:
|
|
raise ValueError(f"public key must be 32 bytes, got {len(public_key)}")
|
|
checksum = hashlib.sha3_256(CHECKSUM_SALT + public_key + ONION_VERSION).digest()[:2]
|
|
encoded = base64.b32encode(public_key + checksum + ONION_VERSION)
|
|
return encoded.decode("ascii").lower() + ".onion"
|
|
|
|
|
|
def _expanded_secret_key(seed: bytes) -> bytes:
|
|
"""Return Tor's 64-byte expanded ed25519 secret key for ``seed``."""
|
|
h = bytearray(hashlib.sha512(seed).digest())
|
|
h[0] &= 248
|
|
h[31] &= 127
|
|
h[31] |= 64
|
|
return bytes(h)
|
|
|
|
|
|
def mint(seed: bytes | None = None) -> OnionKey:
|
|
"""Mint a v3 onion key from ``seed`` (32 random bytes if omitted)."""
|
|
if seed is None:
|
|
seed = os.urandom(SEED_LENGTH)
|
|
if len(seed) != SEED_LENGTH:
|
|
raise ValueError(f"seed must be {SEED_LENGTH} bytes, got {len(seed)}")
|
|
|
|
private = Ed25519PrivateKey.from_private_bytes(seed)
|
|
public_bytes = private.public_key().public_bytes(
|
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
)
|
|
address = onion_address(public_bytes)
|
|
return OnionKey(
|
|
seed=seed,
|
|
address=address,
|
|
hostname=(address + "\n").encode("ascii"),
|
|
public_key=PUBLIC_KEY_HEADER + public_bytes,
|
|
secret_key=SECRET_KEY_HEADER + _expanded_secret_key(seed),
|
|
)
|
|
|
|
|
|
def mint_from_seed_b64(seed_b64: str) -> OnionKey:
|
|
"""Mint from a base64-encoded seed."""
|
|
return mint(base64.b64decode(seed_b64))
|