Virsimo.Tor
0.3.1
dotnet add package Virsimo.Tor --version 0.3.1
NuGet\Install-Package Virsimo.Tor -Version 0.3.1
<PackageReference Include="Virsimo.Tor" Version="0.3.1" />
<PackageVersion Include="Virsimo.Tor" Version="0.3.1" />
<PackageReference Include="Virsimo.Tor" />
paket add Virsimo.Tor --version 0.3.1
#r "nuget: Virsimo.Tor, 0.3.1"
#:package Virsimo.Tor@0.3.1
#addin nuget:?package=Virsimo.Tor&version=0.3.1
#tool nuget:?package=Virsimo.Tor&version=0.3.1
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()— anHttpClientthat reaches.onionaddresses 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.
OnionOnlyis a guardrail, not a sandbox. It blocks non-.onionrequests made throughTorHttpClient; it does not stop other code in your process from opening its own sockets.RunOverTorAsync()binds loopback only. If you useapp.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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
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.