Convert SSH ed25519 key to Onion Service key

(This is a little cursed. I’m not recommending it, I just think it’s cool.)

Today I realized that Tor onion services use ed25519 keys. SSH keys are also often ed25519 keys. Can you convert between them?

Turns out the answer is yes. However, the formats are pretty different, so it took a bit of trial and error.

When talking about ed25519 “private keys”, we mean different data structures depending on the context. What they have in common is use of a seed, which is the actually secret part of the private key. (This framing surprised me; I thought of the “private key” as just the secret thing, but most of the technical explanations I read treated the “private key” as the data structure, and the seed as the only true secret.) We need to extract the seed from the OpenSSH private key format, and save it in the Tor private key format. We also need to generate the Tor public key from the private key, and the onion address from the public key.


In the end, I was able to rely on two existing pieces of code:

  • mk-fg’s ssh-keyparse (blog post), which can parse an OpenSSH ed25519 key and extract the seed (among other things). I was able to completely avoid modifying this nice program.
  • genuineDeveloperBrain’s code in this answer, which can save ed25519 keys generated elsewhere in the Tor format. I made only trivial modifications to this program to make it easier to test with, including importing from pynacl if required.

I have kept them separate since they are composable. Both scripts can be run on the command line or imported in other Python programs. Neither requires any third party libraries, but if you do have pynacl installed, can create a random private key or generate a public key from a private key, which was useful in testing.

#!/usr/bin/env python3

"""Parse ed25519 SSH keys.

From the fgtk
* <>
* <>

import itertools as it, operator as op, functools as ft
import os, sys, io, re, struct, tempfile, hashlib, base64, binascii
import subprocess as sp, pathlib as pl

class SSHKeyError(Exception):

def ssh_key_parse(path, patch_sk=None, decrypt=True):
    with tempfile.NamedTemporaryFile(delete=False, dir=path.parent, + ".") as tmp:

            if decrypt:
                cmd = ["ssh-keygen", "-p", "-P", "", "-N", "", "-f",]
                p =, encoding="utf-8", errors="replace", stdin=sp.DEVNULL, stdout=sp.PIPE, stderr=sp.PIPE)
                stdout, stderr = p.stdout.splitlines(), p.stderr.splitlines()
                err = p.returncode

                if err:
                    if stdout:
                        print("\n".join(stdout), file=sys.stderr)
                    key_enc = False
                    for line in stderr:
                            r"^Failed to load key .*:" r" incorrect passphrase supplied to decrypt private key$", line
                            key_enc = True
                            print(line, file=sys.stderr)
                    if key_enc:
                            "WARNING: !!! ssh key will be decrypted"
                            f" (via ssh-keygen) to a temporary file {!r} in the next step !!!"
                            "WARNING: DO NOT enter key passphrase" " and ABORT operation (^C) if that is undesirable."
                        cmd = ["ssh-keygen", "-p", "-N", "", "-f",]
                        err, p = None,
                            cmd, check=True, encoding="utf-8", errors="replace", stdout=sp.PIPE, stderr=sp.PIPE
                        stdout, stderr = p.stdout.splitlines(), p.stderr.splitlines()

                if err or "Your identification has been saved with the new passphrase." not in stdout:
                    for lines in stdout, stderr:
                        print("\n".join(lines).decode(), file=sys.stderr)
                    raise SSHKeyError(
                            "ssh-keygen failed to process key {}," " see stderr output above for details, command: {}"
                        ).format(path, " ".join(cmd))

            res = _ssh_key_parse(path, tmp, patch_sk)
            except OSError:

    return res

def _ssh_key_parse(path, tmp, patch_sk=None):
    # See PROTOCOL.key and sshkey.c in openssh sources
    lines, key, done =, list(), False
    for line in lines:
        if line == "-----END OPENSSH PRIVATE KEY-----":
            done = True
        if key and not done:
        if line == "-----BEGIN OPENSSH PRIVATE KEY-----":
            if done:
                raise SSHKeyError("More than one" f" private key detected in file, aborting: {path!r}")
            assert not key
    if not done:
        raise SSHKeyError(f"Incomplete or missing key in file: {path!r}")
    key_bytes = base64.standard_b64decode("".join(key))
    key_str_wrap = max(map(len, key))
    key_struct = io.BytesIO(key_bytes)

    def key_read_bytes(src=None):
        if src is None:
            src = key
        (n,) = struct.unpack(">I",

    def key_write_bytes(s, pos=None, dst=None):
        if dst is None:
            dst = key
        if pos:
        dst.write(struct.pack(">I", len(s)))
        return dst.write(s)

    def key_assert(chk, err, *fmt_args, **fmt_kws):
        if chk:
        if fmt_args or fmt_kws:
            err = err.format(*fmt_args, **fmt_kws)
        err += f" [key file: {path!r}, decoded: {key_bytes!r}]"
        raise SSHKeyError(err)

    def key_assert_read(field, val, fixed=False):
        pos, chk = key.tell(), if fixed else key_read_bytes()
            chk == val, "Failed to match key field" " {!r} (offset: {}) - expected {!r} got {!r}", field, pos, val, chk

    key = key_struct
    key_assert_read("AUTH_MAGIC", b"openssh-key-v1\0", True)
    key_assert_read("ciphername", b"none")
    key_assert_read("kdfname", b"none")
    key_assert_read("kdfoptions", b"")
    (pubkey_count,), pubkeys, pos_pk1 = struct.unpack(">I",, list(), list()
    for n in range(pubkey_count):
        pos_line = key.tell()
        line = key_read_bytes()
        key_assert(line, "Empty public key #{}", n)
        line = io.BytesIO(line)
        key_t = key_read_bytes(line).decode()
        key_assert(key_t == "ssh-ed25519", "Unsupported pubkey type: {!r}", key_t)
        pos_pk1.append(pos_line + 4 + line.tell())
        ed25519_pk = key_read_bytes(line)
        line =
        key_assert(not line, "Garbage data after pubkey: {!r}", line)
    pos_privkey_struct = key.tell()
    privkey_struct = io.BytesIO(key_read_bytes())
    pos, tail = key.tell(),
    key_assert(not tail, "Garbage data after private key (offset: {}): {!r}", pos, tail)

    key = privkey_struct
    n1, n2 = struct.unpack(">II",
    key_assert(n1 == n2, "checkint values mismatch in private key spec: {!r} != {!r}", n1, n2)
    key_t = key_read_bytes().decode()
    key_assert(key_t == "ssh-ed25519", "Unsupported key type: {!r}", key_t)
    pos_pk2 = key.tell()
    ed25519_pk = key_read_bytes()
    pos_pk1_idx = list(n for n, pk in enumerate(pubkeys) if pk == ed25519_pk)
    key_assert(pos_pk1_idx, "Pubkey mismatch - {!r} not in {}", ed25519_pk, pubkeys)
    pos_sk = key.tell()
    ed25519_sk = key_read_bytes()
        len(ed25519_pk) == 32 and len(ed25519_sk) == 64,
        "Key length mismatch: {}/{} != 32/64",
    comment = key_read_bytes()
    padding =
    padding, padding_chk = bytearray(padding), bytearray(range(1, len(padding) + 1))
    key_assert(padding == padding_chk, "Invalid padding: {!r} != {!r}", padding, padding_chk)

    if patch_sk:
        assert len(patch_sk) == 64
        key_write_bytes(patch_sk[32:], pos_pk2)
        key_write_bytes(patch_sk, pos_sk)
        key_write_bytes(key.getvalue(), pos_privkey_struct, key_struct)
        for n in pos_pk1_idx:
            key_write_bytes(patch_sk[32:], pos_pk1[n], key_struct)
        key_bytes = key_struct.getvalue()
        tmp.write(b"-----BEGIN OPENSSH PRIVATE KEY-----\n")
        tmp.write(b64encode(key_bytes, line_len=key_str_wrap).encode())
        tmp.write(b"-----END OPENSSH PRIVATE KEY-----\n")
        os.rename(, path)

    return ed25519_sk

def ed25519_pubkey_from_seed(sk):
    # Crypto code here is py3 version of

    b = 256
    q = 2**255 - 19
    l = 2**252 + 27742317777372353535851937790883648493

    def H(m):
        return hashlib.sha512(m).digest()

    def expmod(b, e, m):
        if e == 0:
            return 1
        t = expmod(b, e // 2, m) ** 2 % m
        if e & 1:
            t = (t * b) % m
        return t

    def inv(x):
        return expmod(x, q - 2, q)

    d = -121665 * inv(121666)
    I = expmod(2, (q - 1) // 4, q)

    def xrecover(y):
        xx = (y * y - 1) * inv(d * y * y + 1)
        x = expmod(xx, (q + 3) // 8, q)
        if (x * x - xx) % q != 0:
            x = (x * I) % q
        if x % 2 != 0:
            x = q - x
        return x

    By = 4 * inv(5)
    Bx = xrecover(By)
    B = [Bx % q, By % q]

    def edwards(P, Q):
        x1 = P[0]
        y1 = P[1]
        x2 = Q[0]
        y2 = Q[1]
        x3 = (x1 * y2 + x2 * y1) * inv(1 + d * x1 * x2 * y1 * y2)
        y3 = (y1 * y2 + x1 * x2) * inv(1 - d * x1 * x2 * y1 * y2)
        return [x3 % q, y3 % q]

    def scalarmult(P, e):
        if e == 0:
            return [0, 1]
        Q = scalarmult(P, e // 2)
        Q = edwards(Q, Q)
        if e & 1:
            Q = edwards(Q, P)
        return Q

    def encodeint(y):
        bits = [(y >> i) & 1 for i in range(b)]
        return bytes(sum([bits[i * 8 + j] << j for j in range(8)]) for i in range(b // 8))

    def encodepoint(P):
        x = P[0]
        y = P[1]
        bits = [(y >> i) & 1 for i in range(b - 1)] + [x & 1]
        return bytes(sum([bits[i * 8 + j] << j for j in range(8)]) for i in range(b // 8))

    def bit(h, i):
        return (h[i // 8] >> (i % 8)) & 1

    def publickey(sk):
        h = H(sk)
        a = 2 ** (b - 2) + sum(2**i * bit(h, i) for i in range(3, b - 2))
        A = scalarmult(B, a)
        return encodepoint(A)

    def Hint(m):
        h = H(m)
        return sum(2**i * bit(h, i) for i in range(2 * b))

    def signature(m, sk, pk):
        h = H(sk)
        a = 2 ** (b - 2) + sum(2**i * bit(h, i) for i in range(3, b - 2))
        r = Hint(bytes(h[i] for i in range(b // 8, b // 4)) + m)
        R = scalarmult(B, r)
        S = (r + Hint(encodepoint(R) + pk + m) * a) % l
        return encodepoint(R) + encodeint(S)

    def isoncurve(P):
        x = P[0]
        y = P[1]
        return (-x * x + y * y - 1 - d * x * x * y * y) % q == 0

    def decodeint(s):
        return sum(2**i * bit(s, i) for i in range(0, b))

    def decodepoint(s):
        y = sum(2**i * bit(s, i) for i in range(0, b - 1))
        x = xrecover(y)
        if x & 1 != bit(s, b - 1):
            x = q - x
        P = [x, y]
        if not isoncurve(P):
            raise Exception("decoding point that is not on curve")
        return P

    def checkvalid(s, m, pk):
        if len(s) != b // 4:
            raise Exception("signature length is wrong")
        if len(pk) != b // 8:
            raise Exception("public-key length is wrong")
        R = decodepoint(s[0 : b // 8])
        A = decodepoint(pk)
        S = decodeint(s[b // 8 : b // 4])
        h = Hint(encodepoint(R) + pk + m)
        if scalarmult(B, S) != edwards(R, scalarmult(A, h)):
            raise Exception("signature does not pass verification")

    pk = publickey(sk)
    # m = b'test'
    # s = signature(m, sk, pk)
    # checkvalid(s, m, pk)
    return pk

it_adjacent = lambda seq, n: it.zip_longest(*([iter(seq)] * n))
_b32_abcs = dict(
        # Python base32 - "Table 3: The Base 32 Alphabet" from RFC3548
        # Crockford's base32 -
_b32_abcs["="] = ""

def b32encode(v, chunk=4, _trans=str.maketrans(_b32_abcs), _check="".join(_b32_abcs.values()) + "*~$=U"):
    chksum = 0
    for c in bytearray(v):
        chksum = chksum << 8 | c
    res = "-".join(
        "".join(filter(None, s)) for s in it_adjacent(base64.b32encode(v).decode().strip().translate(_trans), chunk)
    return "{}-{}".format(res, _check[chksum % 37].lower())

def b64decode(data):
    return base64.standard_b64decode(data.replace("-", "+").replace("_", "/"))

def b64encode(data, urlsafe=False, line_len=None):
    enc = base64.standard_b64encode if not urlsafe else base64.urlsafe_b64encode
    data = enc(data).decode()
    if line_len:
        lines = list("".join(filter(None, line)) for line in it_adjacent(data, line_len))
        data = "\n".join(lines + [""])
    return data

def main(args=None):
    path_default = "~/.ssh/id_ed25519"
    path_sys = "/etc/ssh/ssh_host_ed25519_key"

    import argparse

    parser = argparse.ArgumentParser(
        description="OpenSSH ed25519 key processing tool."
        " Prints urlsafe-base64-encoded (by default) 32-byte"
        " secret ed25519 key without any extra wrapping."

    group = parser.add_argument_group("Key specification and operation mode")

    group.add_argument("path", nargs="?", help=f"Path to ssh private key to process. Default: {path_default}")

        help="Derive expanded 64-byte key from"
        " specified base64-encoded 32-byte ed25519 seed value."
        ' "path" argument will be ignored if this option is specified.',
        help="Replace key specified by path argument with one derived from ed25519 seed value."
        " Few bits of (mostly irrelevant) openssh"
        " non-key-material metadata will be left in the keyfile as-is."

        help='Dont use "path" argument and parse sshd key from /etc/ssh.'
        f" Basically a shorthand for specifying {path_sys} as path arg.",
        help="Read and print public key from .pub file alongside private one."
        " This is purely a convenience option to get both backup"
        " of private key and public key for it to paste somewhere.",

    group = parser.add_argument_group("Processing options")
        help="Use one-way PBKDF2 transformation on the raw key."
        " Optional argument allows to control how result"
        ' ("res" format key) and salt ("salt" key) will be combined in the output,'
        ' default: "{salt}{res}".',
        help='PBKDF2 parameters in "algo/rounds/salt-bytes[/salt]" format.'
        ' "salt-bytes" value is only used if salt'
        " value is not specified explicitly, otherwise ignored."
        ' "salt" will be read from /dev/urandom, if omitted. Default: %(default)s',
        help="Truncate result to specified number of bytes before encoding." " Default is to never truncate anything.",

    group = parser.add_argument_group("Encoding options")
        help="Encode result using *urlsafe*" " (aka filesystem-safe) base64 encoding. This is the default.",
        help="Encode result using base64 with standard alphabet (has + and / in it).",
    group.add_argument("-x", "--hex", action="store_true", help='Encode result using "hex" encoding (0-9 + a-f).')
        help="Encode result using readability-oriented Douglas Crockford's Base32 encoding."
        " All visually-confusing symbols (e.g. 1 and l, 0 and O, etc)"
        " in this encoding are interchangeable and case does not matter,"
        " hence easier to read for humans. Check symbol gets added at the end."
        " Format description:",
    group.add_argument("--base32-nodashes", action="store_true", help="Same as --base32, but without dashes.")
        "-r", "--raw", action="store_true", help="Do not encode result in any way, print raw bytes with nothing extra."

    opts = parser.parse_args(sys.argv[1:] if args is None else args)

    path = opts.path
    if opts.system:
        if path:
            parser.error("--system option cannot be used together with path.")
        path = path_sys
    if not path:
        path = path_default
    path = pl.Path(path).expanduser()

    seed = opts.expand_seed or opts.patch_key
    if seed:
        if opts.public:
            parser.error("--public option cannot be used together with --expand-seed/--patch-key.")
        res = b64decode(seed)
        res = res + ed25519_pubkey_from_seed(res)
        if opts.patch_key:
            ssh_key_parse(path, patch_sk=res)
            if not opts.expand_seed:

        if not path.exists():
            parser.error(f"Key path does not exists: {path!r}")
        res = ssh_key_parse(path)
        # assert res == res[:32] + ed25519_pubkey_from_seed(res[:32])
        res = res[:32]

    if opts.pbkdf2:
        algo, rounds, salt = opts.pbkdf2_opts.split("/", 2)
            salt_len, salt = salt.split("/", 1)
        except ValueError:
            salt = os.urandom(int(salt))
        res = hashlib.pbkdf2_hmac(algo, res, salt, int(rounds))

    if opts.truncate:
        res = res[: opts.truncate]

    enc_sum = sum(map(bool, [opts.base64, opts.hex, opts.base32, opts.base32_nodashes, opts.raw]))
    if enc_sum > 1:
        parser.error("At most one encoding option can be used at the same time.")
    if not enc_sum or opts.base64:
        res = b64encode(res, urlsafe=True)
    elif opts.base64_alt:
        res = b64encode(res, urlsafe=False)
    elif opts.hex:
        res = binascii.b2a_hex(res).decode()
    elif opts.base32:
        res = b32encode(res)
    elif opts.base32_nodashes:
        res = b32encode(res).replace("-", "")

    if opts.raw:
        bin_stdout = open(sys.stdout.fileno(), "wb")
        if opts.public:
            parser.error("--public option cannot be used together with --raw.")
    if opts.public:
        print(path.parent / ( + ".pub").read_text().strip())

if __name__ == "__main__":

#!/usr/bin/env python3

"""Save ed25519 keys in the format required by tor.

Thanks to genuineDeveloperBrain, <>

import argparse
import base64
import hashlib
import os
import pdb
import pwd
import sys
import traceback
from pathlib import Path
from typing import Union

def idb_excepthook(type, value, tb):
    """Call an interactive debugger in post-mortem mode

    If you do "sys.excepthook = idb_excepthook", then an interactive debugger
    will be spawned at an unhandled exception
    if hasattr(sys, "ps1") or not sys.stderr.isatty():
        sys.__excepthook__(type, value, tb)
        traceback.print_exception(type, value, tb)

def expand_private_key(secret_key) -> bytes:
    hash = hashlib.sha512(secret_key[:32]).digest()
    hash = bytearray(hash)
    hash[0] &= 248
    hash[31] &= 127
    hash[31] |= 64
    return bytes(hash)

def onion_address_from_public_key(public_key: bytes) -> str:
    version = b"\x03"
    checksum = hashlib.sha3_256(b".onion checksum" + public_key + version).digest()[:2]
    onion_address = "{}.onion".format(base64.b32encode(public_key + checksum + version).decode().lower())
    return onion_address

def verify_v3_onion_address(onion_address: str) -> list[bytes, bytes, bytes]:
    # v3 spec
        decoded = base64.b32decode(onion_address.replace(".onion", "").upper())
        public_key = decoded[:32]
        checksum = decoded[32:34]
        version = decoded[34:]
        if checksum != hashlib.sha3_256(b".onion checksum" + public_key + version).digest()[:2]:
            raise ValueError
        return public_key, checksum, version
        raise ValueError("Invalid v3 onion address")

def create_hs_ed25519_secret_key_content(signing_key: bytes) -> bytes:
    return b"== ed25519v1-secret: type0 ==\x00\x00\x00" + expand_private_key(signing_key)

def create_hs_ed25519_public_key_content(public_key: bytes) -> bytes:
    assert len(public_key) == 32
    return b"== ed25519v1-public: type0 ==\x00\x00\x00" + public_key

def store_bytes_to_file(bytes: bytes, filename: str, uid: int = None, gid: int = None) -> str:
    with open(filename, "wb") as binary_file:
    if uid and gid:
        os.chown(filename, uid, gid)
    return filename

def store_string_to_file(string: str, filename: str, uid: int = None, gid: int = None) -> str:
    with open(filename, "w") as file:
    if uid and gid:
        os.chown(filename, uid, gid)
    return filename

def create_hidden_service_files(
    private_seed: bytes,
    public_key: bytes,
    hidden_service_dir: Union[str, Path],
    tor_username: str,
    overwrite: bool = False,
) -> None:

    if not isinstance(hidden_service_dir, Path):
        hidden_service_dir = Path(hidden_service_dir)

    # these are not strictly needed but takes care of the file permissions need by tor
    user = pwd.getpwnam(tor_username)
    uid = user.pw_uid
    gid = user.pw_gid

    if hidden_service_dir.exists():
        if overwrite:
            print(f"Overwriting any existing files in {str(hidden_service_dir)}")
            raise FileExistsError(f"{str(hidden_service_dir)} already exists.")
        os.chmod(str(hidden_service_dir), 0o700)
        os.chown(str(hidden_service_dir), uid, gid)

    file_content_secret = create_hs_ed25519_secret_key_content(private_seed)

    store_bytes_to_file(file_content_secret, f"{str(hidden_service_dir)}/hs_ed25519_secret_key", uid, gid)

    file_content_public = create_hs_ed25519_public_key_content(public_key)
    store_bytes_to_file(file_content_public, f"{str(hidden_service_dir)}/hs_ed25519_public_key", uid, gid)

    onion_address = onion_address_from_public_key(public_key)
    store_string_to_file(onion_address, f"{str(hidden_service_dir)}/hostname", uid, gid)

    return onion_address

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Create a hidden service from existing key")
    parser.add_argument("--debug", "-d", action="store_true", help="debug")
    parser.add_argument("--private-key", help="32 bytes private key in hex. If not passed, one is generated (requires pynacl).")
    parser.add_argument("--public-key", help="32 bytes public key in hex. If not passed, it's generated from the private key (requires pynacl).")
    parser.add_argument("--tor-user", default="tor", help="Name of tor user")
    parser.add_argument("--force", "-f", action="store_true", help="Force overwrite of existing files")
    parser.add_argument("hidden_service_dir", help="tor hidden service directory")
    parsed = parser.parse_args()

    if parsed.debug:
        sys.excepthook = idb_excepthook

    if not parsed.private_key or not parsed.public_key:
        # Leave imports to runtime so that we can use the important functions in the script without pynacl
            import nacl.bindings
            import nacl.signing
        except ImportError:
            print("pynacl is required to generate a private and/or public key")
    if parsed.public_key and not parsed.private_key:
        parser.error("public key requires private key")

    if parsed.private_key:
        private_key_seed = bytes.fromhex(parsed.private_key)
        privkey = nacl.signing.SigningKey.generate()
        private_key_seed = privkey._seed

    if parsed.public_key:
        public_key = bytes.fromhex(parsed.public_key)
        public_key, secret_key = nacl.bindings.crypto_sign_seed_keypair(private_key_seed)

    onion_address = create_hidden_service_files(

    print(f"Created hidden service with address {onion_address}")

Retrieving the SSH key and saving it as an Onion key

Expand composed function
from set_onion_key import create_hidden_service_files
from ssh_keyparse import _ssh_key_parse

def convert_ssh_ed25519_key_to_onion_hidden_service(
    ssh_ed25519_key_path: Path, hidden_service_dir: Path, tor_username: str
    """Convert an ed25519 SSH key to a Tor Hidden Service key

    This is probably not a good idea! idk anything, I'm crypto stupid.

    Equivalent to something like: -d --private-key "$( /etc/ssh/ssh_host_ed25519_key -x)" /var/lib/tor/services/admin/

    # Hack around the user-friendly 'ssh_key_parse()' (no leading underscore) function
    # to get the raw bytes of the SSH key without dealing with a temp file
    # or having to change any code from the original module.
    with"rb") as f:
        ed25519_privkey_bytes = _ssh_key_parse(None, f, False)

    # The ed25519 SSH key is 64 bytes: 32 bytes of public key, followed by 32 bytes of seed.
    ed25519_public_key = ed25519_privkey_bytes[32:]
    ed25519_privkey_seed = ed25519_privkey_bytes[:32]

    onion_address = create_hidden_service_files(

    return onion_address

This code reads an ed25519 SSH key and saves it in Tor hidden service format to a hidden service dir. To actually use it, you’ll need to add lines to /etc/tor/torrc like:

# This must match the directory you passed as hidden_service_dir above.
HiddenServiceDir /var/lib/tor/services/admin

# Expose whatever is listening on port 80 on the host over Tor
HiddenServicePort 80

Code in context

I am using this in my personal infrastructure project. The context is outside the scope of this post, but you can browse my code on Github if you want.