feat(contrib): add ping key revocation helper
- Add make_ping_key_revocation.py: onion revocations are signed by the identity key, DNS TXT revocations are self-signed by the revoked key - Document the revocation format, endpoint, helper usage, lifetime and no-revocation-of-identity-key limits - Update the security audit, references and env docs for the new flow and the v2/onion-only scope
This commit is contained in:
209
contrib/make_ping_key_revocation.py
Executable file
209
contrib/make_ping_key_revocation.py
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Issue a WeTime ping key revocation certificate.
|
||||
|
||||
A revocation is permanent and key-scoped: every ping signed with the revoked
|
||||
key is rejected for that host until the key is replaced. Two modes exist:
|
||||
|
||||
* Onion delegation (signed by the onion identity key):
|
||||
|
||||
contrib/make_ping_key_revocation.py \\
|
||||
--onion <address>.onion \\
|
||||
--identity-key /var/lib/tor/hidden_service/hs_ed25519_secret_key \\
|
||||
--certificate onion-delegation.json \\
|
||||
--out onion-revocation.json
|
||||
|
||||
* DNSSEC TXT key (self-signed by the key being revoked):
|
||||
|
||||
contrib/make_ping_key_revocation.py \\
|
||||
--host we.example.com \\
|
||||
--key private_key.pem \\
|
||||
--out dns-revocation.json
|
||||
|
||||
Submit the result to the WeTime service (idempotent, safe to retry):
|
||||
|
||||
curl -sS -X POST -H 'Content-Type: application/json' \\
|
||||
--data @onion-revocation.json https://<service>/ping-keys/revoke
|
||||
|
||||
The signed bytes are:
|
||||
|
||||
WELIST-PING-REVOCATION-V1\\n
|
||||
|
||||
{"host":"<host>","key":"<base64 SPKI DER>","revoked_at":<unix>}
|
||||
|
||||
Key-loading and signing helpers are shared with `make_onion_delegation.py`.
|
||||
Requirements: Python 3.10+, `cryptography`, and `pynacl` for Tor-format keys.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
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,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
except ImportError: # pragma: no cover - dependency hint
|
||||
print("error: the 'cryptography' package is required", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
from make_onion_delegation import decode_onion, load_identity_signer, load_ping_key
|
||||
|
||||
REVOCATION_PREFIX = b"WELIST-PING-REVOCATION-V1\n"
|
||||
|
||||
|
||||
def validate_key_b64(key_b64: str) -> str:
|
||||
"""Return `key_b64` after checking it is a base64 Ed25519 SPKI key."""
|
||||
try:
|
||||
der = base64.b64decode(key_b64, validate=True)
|
||||
public_key = serialization.load_der_public_key(der)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"invalid delegated key: {exc}") from exc
|
||||
if not isinstance(public_key, Ed25519PublicKey):
|
||||
raise ValueError("delegated key is not Ed25519")
|
||||
return key_b64
|
||||
|
||||
|
||||
def load_certificate(path: Path) -> tuple[str, str]:
|
||||
"""Return `(onion, key_b64)` from a delegation certificate."""
|
||||
try:
|
||||
certificate = json.loads(path.read_text())
|
||||
onion = str(certificate["onion"]).strip().lower().rstrip(".")
|
||||
key = str(certificate["key"]).strip()
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
raise ValueError(f"cannot read delegation certificate {path}: {exc}") from exc
|
||||
return onion, validate_key_b64(key)
|
||||
|
||||
|
||||
def load_ping_private_key(path: Path) -> Ed25519PrivateKey:
|
||||
"""Load a PKCS#8 Ed25519 private key."""
|
||||
try:
|
||||
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"cannot read private key {path}: {exc}") from exc
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise ValueError(f"{path} is not an Ed25519 private key")
|
||||
return key
|
||||
|
||||
|
||||
def canonical_message(host: str, key_b64: str, revoked_at: int) -> bytes:
|
||||
"""Build the exact bytes covered by the revocation signature."""
|
||||
body = {"host": host, "key": key_b64, "revoked_at": revoked_at}
|
||||
encoded = json.dumps(
|
||||
body, separators=(",", ":"), sort_keys=True, ensure_ascii=False
|
||||
).encode("utf-8")
|
||||
return REVOCATION_PREFIX + encoded
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
target = parser.add_mutually_exclusive_group()
|
||||
target.add_argument("--onion", help="onion host (address.onion)")
|
||||
target.add_argument("--host", help="DNS hostname of the executor")
|
||||
parser.add_argument("--identity-key",
|
||||
help="Tor hs_ed25519_secret_key or PKCS#8 PEM identity key (onion mode)")
|
||||
parser.add_argument("--certificate",
|
||||
help="existing onion delegation certificate (reads host and key)")
|
||||
parser.add_argument("--key", "--ping-key", dest="key",
|
||||
help="PKCS#8 PEM of the key to revoke; also the signer for DNS mode")
|
||||
parser.add_argument("--revoked-at", type=int,
|
||||
help="revocation Unix timestamp (default: now)")
|
||||
parser.add_argument("--out", default="ping-key-revocation.json",
|
||||
help="output JSON path (default: ping-key-revocation.json)")
|
||||
parser.add_argument("--service-url",
|
||||
help="WeTime service base URL, used to print the curl command")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.revoked_at is not None and args.revoked_at <= 0:
|
||||
print("error: --revoked-at must be positive", file=sys.stderr)
|
||||
return 2
|
||||
revoked_at = args.revoked_at if args.revoked_at is not None else int(time.time())
|
||||
|
||||
mode: str
|
||||
host: str
|
||||
key_b64: str
|
||||
|
||||
if args.certificate:
|
||||
mode = "onion"
|
||||
certificate_onion, key_b64 = load_certificate(Path(args.certificate))
|
||||
host = args.onion.strip().lower().rstrip(".") if args.onion else certificate_onion
|
||||
if host != certificate_onion:
|
||||
print(
|
||||
f"error: certificate is bound to {certificate_onion}, not {host}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
if args.key:
|
||||
certificate_key = load_ping_key(Path(args.key))
|
||||
if certificate_key != key_b64:
|
||||
print("error: --key does not match the certificate key", file=sys.stderr)
|
||||
return 2
|
||||
elif args.onion:
|
||||
mode = "onion"
|
||||
host = args.onion.strip().lower().rstrip(".")
|
||||
if not args.key:
|
||||
print("error: onion revocations require --key or --certificate", file=sys.stderr)
|
||||
return 2
|
||||
key_b64 = validate_key_b64(load_ping_key(Path(args.key)))
|
||||
elif args.host:
|
||||
mode = "dns"
|
||||
host = args.host.strip().lower().rstrip(".")
|
||||
if not args.key:
|
||||
print("error: DNS revocations require --key", file=sys.stderr)
|
||||
return 2
|
||||
if args.identity_key:
|
||||
print("error: --identity-key is only valid for onion hosts", file=sys.stderr)
|
||||
return 2
|
||||
key_b64 = validate_key_b64(load_ping_key(Path(args.key)))
|
||||
else:
|
||||
print("error: provide --onion or --host", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not host or host.endswith("."):
|
||||
print("error: invalid host", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
message = canonical_message(host, key_b64, revoked_at)
|
||||
|
||||
if mode == "onion":
|
||||
if not args.identity_key:
|
||||
print("error: onion revocations require --identity-key", file=sys.stderr)
|
||||
return 2
|
||||
public_key = decode_onion(host)
|
||||
signer = load_identity_signer(Path(args.identity_key), public_key)
|
||||
signature = signer(message)
|
||||
else:
|
||||
signing_key = load_ping_private_key(Path(args.key))
|
||||
signature = signing_key.sign(message)
|
||||
certificate = {
|
||||
"host": host,
|
||||
"key": key_b64,
|
||||
"revoked_at": revoked_at,
|
||||
"signature": base64.b64encode(signature).decode("ascii"),
|
||||
}
|
||||
Path(args.out).write_text(json.dumps(certificate, indent=2) + "\n")
|
||||
|
||||
service = (args.service_url or "https://<service>").rstrip("/")
|
||||
print(f"wrote {args.out} (host={host}, revoked_at={revoked_at})")
|
||||
print("submit with:")
|
||||
print(
|
||||
" curl -sS -X POST -H 'Content-Type: application/json' "
|
||||
f"--data @{args.out} {service}/ping-keys/revoke"
|
||||
)
|
||||
if mode == "dns":
|
||||
print("also remove the revoked key from the _welist-key TXT record.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except ValueError as err:
|
||||
print(f"error: {err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user