Virsimo.Tor 0.3.1

dotnet add package Virsimo.Tor --version 0.3.1
                    
NuGet\Install-Package Virsimo.Tor -Version 0.3.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Virsimo.Tor" Version="0.3.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Virsimo.Tor" Version="0.3.1" />
                    
Directory.Packages.props
<PackageReference Include="Virsimo.Tor" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Virsimo.Tor --version 0.3.1
                    
#r "nuget: Virsimo.Tor, 0.3.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Virsimo.Tor@0.3.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Virsimo.Tor&version=0.3.1
                    
Install as a Cake Addin
#tool nuget:?package=Virsimo.Tor&version=0.3.1
                    
Install as a Cake Tool

Virsimo.Tor

Pre-1.0 — 0.3.1. The API may still change before 1.0.

Publish an ASP.NET Core app as a Tor hidden service, and call other .onion services from it.

  • builder.AddTorService() — registers the hidden service over Tor's control port at startup and removes it on shutdown.
  • app.RunOverTorAsync() — serves only over Tor, on a random loopback port.
  • services.AddTorHttpClient() — an HttpClient that reaches .onion addresses through Tor's SOCKS proxy, and refuses everything else.

It also fixes the thing that bites everyone the first time: ASP.NET Core's host filtering rejects the .onion Host header with Bad Request - Invalid Hostname. The .onion address isn't known until Tor hands it out, so this package feeds it into HostFilteringOptions at runtime.

Requirements

  • .NET 11
  • A running Tor daemon with the control port enabled — see Setting up Tor

Setting up Tor

Tor will not accept control connections until you enable the control port and give it a password hash. This is a one-time setup on the machine running Tor.

1. Generate the password hash

Tor stores a salted hash of the password, never the password itself. Generate one with Tor's own binary:

tor --hash-password "your-password"

It prints a single line — that is the hash, not your password:

16:872860B76453A77D60CA2BB8C1A7042072093276A3D701AD684053EC4C

Finding the binary:

Platform Command
Debian/Ubuntu (apt install tor) tor --hash-password "your-password"
macOS (brew install tor) tor --hash-password "your-password"
Windows (Tor Expert Bundle) .\tor.exe --hash-password "your-password"
Windows (Tor Browser) "%ProgramFiles%\Tor Browser\Browser\TorBrowser\Tor\tor.exe" --hash-password "your-password"
Docker docker run --rm dperson/torproxy tor --hash-password "your-password"

Pick a long random password — it is a full-privilege credential, not a login you'll type:

openssl rand -base64 32          # or: pwsh -c "[Convert]::ToBase64String((1..32|%{Get-Random -Max 256}))"

2. Put the hash in torrc

Add these lines to your torrc, using the hash from step 1:

ControlPort 9051
HashedControlPassword 16:872860B76453A77D60CA2BB8C1A7042072093276A3D701AD684053EC4C
SocksPort 9050

Where torrc lives:

Platform Path
Debian/Ubuntu /etc/tor/torrc
macOS (Homebrew) /opt/homebrew/etc/tor/torrc or /usr/local/etc/tor/torrc
Windows (Expert Bundle) <install dir>\Data\Tor\torrc
Windows (Tor Browser) %ProgramFiles%\Tor Browser\Browser\TorBrowser\Data\Tor\torrc

3. Restart Tor

sudo systemctl restart tor        # Linux
brew services restart tor         # macOS

4. Give the app the plaintext password

The app sends the password, and Tor checks it against the hash. Keep it out of the repository — see Secrets:

dotnet user-secrets set "TorHttpOptions:ControlPassword" "your-password"

5. Check it works

printf 'AUTHENTICATE "your-password"\r\nGETINFO version\r\nQUIT\r\n' | nc 127.0.0.1 9051

250 OK means you're set. 515 Authentication failed means the password doesn't match the hash in torrc; connection refused means ControlPort isn't enabled or Tor isn't running.

This package authenticates with a password. Tor's cookie authentication (CookieAuthentication 1) is not supported yet.

Install

dotnet add package Virsimo.Tor

Quick start

Copy this into Program.cs — it's a complete, working hidden service:

var builder = WebApplication.CreateBuilder(args);

// Publishes this app as a hidden service and allows its .onion Host header.
builder.AddTorService();

// An HttpClient for calling other hidden services (.onion only, by default).
builder.Services.AddTorHttpClient();

var app = builder.Build();

app.MapGet("/", () => "Hello from a Tor hidden service!");

// Serves only over Tor: binds one random loopback port, nothing else.
await app.RunOverTorAsync();

Store the control password outside the repo (see Secrets):

dotnet user-secrets init
dotnet user-secrets set "TorHttpOptions:ControlPassword" "your-password"

Run it, and the log line tells you the address:

info: Virsimo.Tor.TorService[0]
      Tor service started at http://abcdef...xyz.onion -> 127.0.0.1:52431

Open that address in Tor Browser.

Keeping the same address across restarts

By default a new key — and so a new .onion address — is generated on every start. To keep one address, pass an existing ED25519-V3 key:

dotnet user-secrets set "TorHttpOptions:PrivateKey" "<base64 key>"

To obtain a key, run once without one and read PrivateKey= from Tor's ADD_ONION reply, or generate a key pair offline with a tool such as mkp224o.

Configuration

Bound from the TorHttpOptions section of configuration.

Setting Default What it does
ControlHost 127.0.0.1 Tor control port host.
ControlPort 9051 Tor control port.
ControlPassword my_password Must match HashedControlPassword in torrc. Keep it out of source control.
SocksHost 127.0.0.1 Tor SOCKS proxy host, used by TorHttpClient.
SocksPort 9050 Tor SOCKS proxy port.
PrivateKey (empty) Base64 ED25519-V3 key. Empty means a fresh address each start.
LocalPort 0 Port the hidden service forwards to. 0 uses whatever port the app actually bound.
OnionOnly true Restricts TorHttpClient to .onion hosts.
Timeout 100s Timeout for clients created by AddTorHttpClient.
{
  "TorHttpOptions": {
    "ControlHost": "127.0.0.1",
    "ControlPort": 9051,
    "SocksPort": 9050,
    "OnionOnly": true
  }
}

Samples

Sample Shows Needs
samples/HiddenServiceSample AddTorService() and RunOverTorAsync() — a minimal API published as a hidden service. Control port + password
samples/TorHttpClientSample AddTorHttpClient() — a console app fetching a .onion service, and the .onion restriction refusing the clearnet. SocksPort only
dotnet run --project samples/TorHttpClientSample
dotnet run --project samples/HiddenServiceSample

Secrets

Two settings are sensitive: ControlPassword (a full-privilege credential for the Tor daemon) and PrivateKey (whoever holds it can impersonate your .onion address). Neither belongs in appsettings.json.

In development — user secrets. They live outside the project directory, so they can't be committed by accident:

cd samples/HiddenServiceSample          # or your own project
dotnet user-secrets init                # adds a UserSecretsId to the .csproj
dotnet user-secrets set "TorHttpOptions:ControlPassword" "your-password"
dotnet user-secrets set "TorHttpOptions:PrivateKey" "<base64 key>"
dotnet user-secrets list

WebApplication.CreateBuilder reads them automatically in the Development environment — no code change needed. They are stored as plain JSON under %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json (~/.microsoft/usersecrets/<id>/ on Linux and macOS). That keeps them out of source control; it is not encryption, so treat the file as sensitive.

In production — environment variables or a secret store. Configuration keys map to environment variables with __ as the separator:

export TorHttpOptions__ControlPassword='your-password'
export TorHttpOptions__PrivateKey='<base64 key>'

For anything beyond a single host, prefer a real secret store (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, Docker/Kubernetes secrets) wired in as a configuration provider.

Public API

builder.AddTorService()

Registers the hosted service that publishes the app as a hidden service. At startup it connects to the control port, authenticates, and issues ADD_ONION pointing at the port the app is really listening on; on shutdown it issues DEL_ONION. It also registers the .onion address with host filtering once it is known.

Use it with app.Run() if you want the app to keep serving its normal endpoints and be reachable over Tor.

app.RunOverTorAsync(CancellationToken)

Runs the app as a hidden service only. Any configured URLs (ASPNETCORE_URLS, applicationUrl, UseUrls) are discarded and replaced with a single loopback endpoint on a random free port, so the app cannot be reached except through Tor. Pin the port with LocalPort if you need to.

Throws InvalidOperationException if AddTorService() was not called.

services.AddTorHttpClient()

Registers TorHttpClient — a real HttpClient subclass — as a singleton. Every request goes through Tor's SOCKS proxy, with the .onion hostname handed to Tor rather than resolved locally.

You can also construct it directly, without dependency injection. The parameterless constructor uses Tor's default location, 127.0.0.1:9050:

using Virsimo.Tor;

TorHttpClient tor = new();
var result = await tor.GetStringAsync("http://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion");
Console.WriteLine(result);

By default it throws HttpRequestException for any host that is not .onion, so a wrong or attacker-supplied URL cannot leave the hidden service network through an exit node. Lift it deliberately:

builder.Services.AddTorHttpClient(options => options.OnionOnly = false);

There is also services.AddTorHttpClient<TClient>() for an IHttpClientFactory typed client that takes an HttpClient constructor parameter.

How it works

Concern What the package does
Bad Request - Invalid Hostname Adds the .onion address to HostFilteringOptions.AllowedHosts at runtime, via an options change token, keeping your configured AllowedHosts intact.
Which port to publish Reads the port Kestrel actually bound from IServerAddressesFeature after startup, so ADD_ONION can never point at the wrong port.
Control protocol Full reply parsing per control-spec §2.3: - continuation lines, + data blocks with dot-stuffing, and 6xx async events skipped.
Shutdown DEL_ONION before the control connection closes.

Security notes

  • The control password is a full-privilege credential. Anything that can authenticate to Tor's control port can reconfigure Tor. Use user secrets in development and environment variables or a secret store in production; never commit it.
  • OnionOnly is a guardrail, not a sandbox. It blocks non-.onion requests made through TorHttpClient; it does not stop other code in your process from opening its own sockets.
  • RunOverTorAsync() binds loopback only. If you use app.Run() instead, your normal endpoints stay exposed on whatever interfaces you configured.
  • A hidden service reachable over Tor is still your app: authentication, rate limiting, and input validation matter as much as anywhere else.

Troubleshooting

Symptom Cause
Bad Request - Invalid Hostname AddTorService() not called, or the request arrived before the hidden service finished registering.
Failed to authenticate with Tor control port: 515 Wrong ControlPassword, or HashedControlPassword not set in torrc.
Connection refused on startup Tor isn't running, or ControlPort is wrong / not enabled in torrc.
The .onion address changes on every restart No PrivateKey configured.
Tor Browser can't reach the address Hidden service descriptors take up to a minute to publish after startup.

Building

dotnet build
dotnet test
dotnet pack src/Virsimo.Tor -c Release

The test suite covers the control protocol parser, the .onion restriction, and the full hidden service lifecycle against a stubbed control port and a stubbed SOCKS5 proxy — no Tor daemon required.

License

Copyright (c) 2026 Virsimo. All rights reserved. This software is proprietary: no permission is granted to use, copy, modify or distribute it without prior written permission from the copyright holder. See LICENSE.txt.

Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net11.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.1 138 8/4/2026
0.3.0 116 8/4/2026