I'm making an onion (v3) address generator in Python. Here is the part that generates the keypair:

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import x25519

def v3KeyGen():
    private_key = x25519.X25519PrivateKey.generate()
    private_bytes = private_key.private_bytes(encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption())

    public_key = private_key.public_key()
    public_bytes = public_key.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)

    return public_bytes, private_bytes

What file format do I put the keys in (e.g. the format of the hs_ed25519_secret_key file), to get them to actually work with something like a Tor hidden service? Right now I have these keys, but have no idea how I actually get Tor to use them.

Edit: This is what an example private key file generated by Tor itself looks like, with some sort of binary data, I have no idea how to interpret it:

enter image description here

2 Answers 2


To interpret the hs_ed25519_secret_key and hs_ed_25519_public_key files you can read/write the files in binary format. The first 32 bytes of these files are b"== ed25519v1-secret: type0 ==\x00\x00\x00" and b"== ed25519v1-public: type0 ==\x00\x00\x00". Examples below.

I was trying to solve a similar problem, but wanted to generate Tor hidden service files based on existing ED25519 keys. Since this was poorly documented, I included the code here for others in need later. The code is written in Python, but heavily inspired by the go-code from the comment by Alexey Vesnin.

import base64
import hashlib
import os
import pwd
import grp
from pathlib import Path

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 https://gitweb.torproject.org/torspec.git/plain/rend-spec-v3.txt
        decoded = base64.b32decode(onion_address.replace(".onion", "").upper())
        public_key = decoded[:32]
        checksum = decoded[32:34]
        version = decoded[34:]
        if (
            != 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(

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_key: bytes,
    public_key: bytes,
    tor_data_directory: str,
    hidden_service_dir: str,
) -> None:

    path = Path(tor_data_directory)
    parent = path.parent.absolute()
    # these are not strictly needed but takes care of the file permissions need by tor
    tor_user = parent.owner()
    tor_group = parent.group()
    uid = pwd.getpwnam(tor_user).pw_uid
    gid = grp.getgrnam(tor_group).gr_gid
    if not path.exists():
        os.chmod(tor_data_directory, 0o700)
        os.chown(tor_data_directory, uid, gid)

    file_content_secret = create_hs_ed25519_secret_key_content(private_key)

        file_content_secret, f"{hidden_service_dir}/hs_ed25519_secret_key", uid, gid

    file_content_public = create_hs_ed25519_public_key_content(public_key)
        file_content_public, f"{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"{hidden_service_dir}/hostname", uid, gid)

if __name__ == "__main__":
        b"------32-bytes-private_key------",  # the ed25519 private key often includes the public key, this does not

ED25519 keys can be generated with the pynacl or cryptography packages.


The data you've displayed on a screenshot is a binary displayed as a char stream, you need to put it in the right format. I have a good example on Go - here is the code, I'm sure you'll spot the format specs out of it :

// Example of generating Tor onion using the new ED25519-v3 format

package main

import (

// Hidden service version
const version = byte(0x03)

// Salt used to create checkdigits
const salt = ".onion checksum"

func main() {
    pub, pri, err := ed25519.GenerateKey(rand.Reader)
    if err != nil {
    fmt.Println("Address:", getServiceID(pub)+".onion")
    fmt.Println("Private Key:", expandKey(pri))

// Expand ed25519.PrivateKey to (a || RH) form, return base64
func expandKey(pri ed25519.PrivateKey) string {
    h := sha512.Sum512(pri[:32])
    // Set bits so that h[:32] is private scalar "a"
    h[0] &= 248
    h[31] &= 127
    h[31] |= 64
    // Since h[32:] is RH, h is now (a || RH)
    return base64.StdEncoding.EncodeToString(h[:])

func getCheckdigits(pub ed25519.PublicKey) []byte {
    // Calculate checksum sha3(".onion checksum" || publicKey || version)
    checkstr := []byte(salt)
    checkstr = append(checkstr, pub...)
    checkstr = append(checkstr, version)
    checksum := sha3.Sum256(checkstr)
    return checksum[:2]

func getServiceID(pub ed25519.PublicKey) string {
    // Construct onion address base32(publicKey || checkdigits || version)
    checkdigits := getCheckdigits(pub)
    combined := pub[:]
    combined = append(combined, checkdigits...)
    combined = append(combined, version)
    serviceID := base32.StdEncoding.EncodeToString(combined)
    return strings.ToLower(serviceID)

The source credit is https://gist.github.com/wybiral/8f737644fc140c97b6b26c13b1409837 - I used it myself when I needed to do exactly the same thing

  • Thanks for that, I know how to generate the address, just not sure how to put the key into a correct file that is valid for a Tor hidden service. Is the expandKey function what I'm looking for? I'm not familiar with Go, so I have no idea what the &= and |= operators mean
    – scob_
    Sep 18, 2021 at 9:16
  • Basically, where does the == ed25519v1-secret: type0 == come from, because I don't see it in that code either
    – scob_
    Sep 18, 2021 at 9:39
  • where have you found it? I don't see it in the code
    – Alexey Vesnin
    Sep 18, 2021 at 10:35
  • In the example private key file I included at the bottom of the question that was generated by Tor. I'm asking how to put the keys I generate into a file like that, so that Tor can actually use them
    – scob_
    Sep 18, 2021 at 21:44
  • 1
    @scob_ exactly like this github.com/cathugger/mkp224o/blob/master/worker.c#L149
    – Alexey Vesnin
    Sep 25, 2021 at 16:28

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .