Skip to content

Instantly share code, notes, and snippets.

@lsd-cat
Last active August 11, 2025 17:34
Show Gist options
  • Select an option

  • Save lsd-cat/d3bd05412d83628be7145462081462f9 to your computer and use it in GitHub Desktop.

Select an option

Save lsd-cat/d3bd05412d83628be7145462081462f9 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
# ctor -> arti onion service discovery key conversion script
# Usage: ./hss_ctor_to_arti.py <base32 secret> > /path/to/arti/keystore/client/<hsid>/ks_hsc_desc_enc.x25519_private
# <base32 secret> is the last part in the ctor client auth file
# <hsid> is the Onion Service hostname without .onion
# Any extra trailing space or newline at the end of the file will break the format
# <hsid> folder must be chmod 700
# ks_hsc_desc_enc.x25519_private must be chmod 600
import base64, os, struct, sys
from cryptography.hazmat.primitives.asymmetric import x25519
from cryptography.hazmat.primitives import serialization
SEED_VALUE = sys.argv[1]
COMMENT = "" # optional comment stored inside the key
v = SEED_VALUE.strip().upper()
v += "=" * ((8 - (len(v) % 8)) % 8) # missing paddong
seed = base64.b32decode(v, casefold=True)
# Clamp per RFC7748 §5 (X25519)
k = bytearray(seed)
k[0] &= 248
k[31] &= 127
k[31] |= 64
k = bytes(k)
# Derive public key
try:
pub = x25519.X25519PrivateKey.from_private_bytes(k).public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
except Exception as e:
raise SystemExit(f"Error importing key: {e}")
ALG = b"x25519@spec.torproject.org"
def ssh_string(b: bytes) -> bytes:
return struct.pack(">I", len(b)) + b
# Build the outer OpenSSH header
magic = b"openssh-key-v1\0"
ciphername = b"none"
kdfname = b"none"
kdfopts = b""
nkeys = 1
outer = [
magic,
ssh_string(ciphername),
ssh_string(kdfname),
ssh_string(kdfopts),
struct.pack(">I", nkeys),
]
# Public key section (one key): string( keyblob ), where keyblob = string(name) + string(pubdata)
pubkey_blob = ssh_string(ALG) + ssh_string(pub) # pubdata is a string-wrapped 32 bytes
outer.append(ssh_string(pubkey_blob))
# Private section (unencrypted blob)
check = os.urandom(4)
priv_payload = bytearray()
priv_payload += check + check # checkint1, checkint2
# One key:
priv_payload += ssh_string(ALG)
priv_payload += ssh_string(pub) # public key data (string-wrapped 32 bytes)
priv_payload += ssh_string(k) # private key data (string-wrapped 32-byte scalar)
priv_payload += ssh_string(COMMENT.encode())
# Padding: 1..n so total is multiple of 8 bytes
block = 8
pad_len = (-len(priv_payload)) % block
if pad_len:
priv_payload += bytes((i % 256 for i in range(1, pad_len + 1)))
outer.append(ssh_string(bytes(priv_payload)))
key_blob = b"".join(outer)
# Write PEM-like OpenSSH key
b64 = base64.b64encode(key_blob).decode()
lines = [b64[i:i+70] for i in range(0, len(b64), 70)]
print("-----BEGIN OPENSSH PRIVATE KEY-----")
print("\n".join(lines))
print("-----END OPENSSH PRIVATE KEY-----")% (.venv) g@Giulios-MacBook-Air-2 Work %
@deeplow
Copy link

deeplow commented Aug 11, 2025

This seems to be working well. TYSM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment