Skip to content

Instantly share code, notes, and snippets.

@dmarzzz
Created June 4, 2026 08:35
Show Gist options
  • Select an option

  • Save dmarzzz/ec30127e1713b66756c87e091e417c45 to your computer and use it in GitHub Desktop.

Select an option

Save dmarzzz/ec30127e1713b66756c87e091e417c45 to your computer and use it in GitHub Desktop.
reputation-gated onion egress: a zk-proof-gated Tor egress proxy (Semaphore + RLN-style nullifiers). Full repo: github.com/dmarzzz/reputation-gated-onion-egress

reputation-gated onion egress

Canonical repo (with full directory structure): https://github.com/dmarzzz/reputation-gated-onion-egress

This gist is a flattened snapshot of the core files. Paths in the repo are gateway/, client/, lib/, group/, scripts/, tor/.


reputation-gated onion egress

A working proof of concept: a Tor onion service that egresses to the clearnet only for clients that prove, in zero knowledge, that they hold reputation, that is, that they belong to a curated set of members in good standing. Everyone else is dropped before a single byte leaves.

You use Tor with this as your destination. The gateway address is a .onion, so your Tor client routes to it natively, with no exit node in the path. The gateway then makes the real outbound request from its own clean IP.

Why this exists

Start from why SearXNG over Tor gets blocked: Tor exit IPs are a public, auto-blockable list with perpetually bad reputation (torbulkexitlist, FireHOL tor_exits). The usual escape is a residential proxy, which is an anonymity anti-pattern: you trade IP-reputation evasion for a fully-trusted third party who links every request to your billing identity.

The real constraint underneath both is simple. An open egress on a clean IP becomes a blocklisted IP within hours, because spammers find it and abuse it. Clean IPs stay clean only because they are gated and scarce. The residential proxy market is just the commercial version of that gate, paid for by surrendering anonymity.

This PoC gates the egress with a proof instead of an identity. The zero-knowledge reputation proof is the mechanism that lets a clean-IP egress stay clean without ever learning who its users are. It swaps the thing the whole problem turns on, per-IP reputation, for per-member reputation that is anonymous and portable: sybil and rate resistance decoupled from identity and from IP.

Can Tor do this natively? No, and that turns out to be fine.

Worth being precise, because it shaped the design.

There is no slot in the Tor protocol to carry "a proof that you hold reputation." Tor cells are opaque; the exit speaks no application semantics. You cannot make stock torify curl emit a proof, and you cannot teach an exit to demand one without forking Tor and getting your fork adopted by relay operators. The one native hook (onion-service v3 client authorization) is a static per-client keypair allowlist: linkable and identity-bearing, the opposite of what we want.

So the reputation gate rides on top of Tor as a thin application-layer protocol, it does not modify Tor. What Tor does give us for free is the part that makes "use Tor as my destination" literally true: onion services. The gateway is published as a .onion, reached by rendezvous, so:

  • there is no exit node between client and gateway, hence no exit-IP problem,
  • the gateway never learns the client IP (rendezvous routing),
  • the gateway is addressed the way every other Tor destination is.

The architecture is not "Tor plus a bolted-on hop." It is a reputation-gated egress proxy published as an onion service, with a small proof-carrying protocol on top.

Architecture

  curl / SearXNG                       your machine
  http_proxy=127.0.0.1:8888
        |
        v
  client shim (client/shim.mjs)  -- generates a Semaphore membership proof,
        |                            caches it for the epoch
        |  SOCKS5 to Tor (127.0.0.1:9250), no exit node
        v
  Tor rendezvous  (3 + 3 hops; client IP never revealed to the gateway)
        |
        v
  gateway.onion  ->  gateway (gateway/gateway.mjs)        the egress box
        |   1. verify the zk proof  (valid?)
        |   2. is the proof against OUR reputation set?  (root match)
        |   3. is it this epoch?  (freshness)
        |   4. is this anonymous member within its rate budget?  (RLN-style)
        |   drop on any failure
        v
  clean egress IP  --->  google.com / target site

TLS stays end to end between your client and the destination. The gateway tunnels at the TCP layer (CONNECT to :443), so it sees the destination host and nothing else. It cannot read your traffic.

What is zero-knowledge here

The reputation set is a Semaphore group: a Merkle tree of identity commitments. The client proves it owns the secret behind some leaf, without revealing which leaf. The proof carries a nullifier derived from (secret, scope), and we set scope = current epoch:

  • within an epoch, one member always produces the same nullifier, so the gateway can rate-limit per member without knowing who they are,
  • across epochs the nullifier changes, so requests are unlinkable over time.

That is the RLN (rate-limiting nullifier) idea at PoC fidelity: anonymous membership plus anonymous rate limiting.

Quickstart

Requires tor (brew install tor) and Node 18+.

npm install
node group/enroll.mjs alice          # adds a member to the set, prints a client secret
export RGOE_SECRET=...               # the secret it printed
bash scripts/run-all.sh              # starts tor + gateway + shim
curl -x http://127.0.0.1:8888 'https://api.ipify.org?format=json'

First request pays a one-time ~5s Semaphore proof generation (and, on a fresh machine, a one-time SNARK-artifact download that can take a few minutes). After that the proof is cached for the epoch and requests are fast.

Right after tor starts, the onion descriptor takes a little while to propagate before the first rendezvous succeeds, so the very first request can be slow or time out once. The shim retries through this automatically; just run the curl again if the first one stalls.

Stop everything with bash scripts/stop.sh.

Use it with SearXNG

Point SearXNG's outgoing requests at the shim:

# settings.yml
outgoing:
  proxies:
    https: http://127.0.0.1:8888
    http:  http://127.0.0.1:8888

See the gate drop non-members

node scripts/probe.mjs noproof       # -> gate:no-proof
node scripts/probe.mjs garbage       # -> gate:invalid-proof
node scripts/probe.mjs wronggroup    # valid proof, forged group -> gate:wrong-group-root

Deploy for a genuinely clean egress IP

Run locally and the egress IP is your home IP, which is already a clean residential IP and beats a Tor exit. To get a separate clean egress, run the gateway and tor on a VPS:

  1. On the VPS: install tor + node, npm install, bash scripts/start-tor.sh, node gateway/gateway.mjs. Copy out tor/hs/hostname and group/members.json.
  2. On your machine: run only the shim, pointing at the VPS onion and set: export RGOE_ONION=<hostname> and run the shim against your local tor.

The gateway is portable: it is just a TCP server behind an onion address. Nothing about it is tied to this machine.

Threat model and honest limits

What this gets right:

  • Client is anonymous to the gateway. Onion rendezvous, no exit node, no IP.
  • No identity anywhere. Membership is proven, never named. The gateway logs a per-epoch nullifier, which is unlinkable across epochs.
  • Forged proofs are rejected. A valid Semaphore proof against an attacker-invented group fails the trusted-root check (verified, see probe).
  • Anonymous rate limiting. Per-nullifier per-epoch budget, with no idea whose.
  • Metadata-only. TLS is end to end; the gateway sees host:port, not content.

What it does not solve, stated plainly:

  • The reputation set is the trust root. The proof gates on membership in a set; it does not create reputation. Whatever ceremony adds a leaf in group/enroll.mjs is what "reputable" means. In production that leaf is added only after whatever the admission policy requires: a stake, an invite, accrued good standing, or a proof-of-personhood check (World ID, which is itself Semaphore-based). The PoC ceremony is a local command. This moves the sybil problem to the admission policy, it does not dissolve it. The win is that the policy can be as strict as you like while the gate still reveals nothing about which member is at the door.
  • One clean IP at high volume still looks like a bot. Clean reputation needs low per-IP volume, which fights with serving many users from one gateway. The PoC proves the mechanism. Scaling it is a fleet-of-clean-IPs plus incentive question, the same scarce-clean-IP problem the residential-proxy report dissected. Out of scope here on purpose.
  • Within-epoch linkability is the cost of rate limiting. A shorter epoch means tighter limits and more linkability inside the window; a longer epoch means the reverse. That tension is inherent to RLN, not a bug.
  • Replay inside an epoch is allowed by design. The cached proof is re-sent each request and the gateway counts each redemption. The proof only ever travels inside the Tor-encrypted tunnel, so it is not exposed to an eavesdropper. Production RLN binds a fresh share per message and slashes over-rate secrets.
  • DoS on the rendezvous. Anyone who knows the .onion can force proof verification work. Tor's onion-service proof-of-work defense is the intended outer gate (see tor/torrc); the Homebrew tor build ships without the pow module, so it is commented out. The zk gate still bounds egress regardless.

Layout

Path What it is
lib/semaphore.mjs Shared: load the group, prove, verify, epoch math
group/enroll.mjs Add a member to the reputation set (the trust boundary)
group/members.json The published reputation set (identity commitments only)
gateway/gateway.mjs Onion-side egress proxy: verify, rate-limit, tunnel, drop
client/shim.mjs Local CONNECT proxy: prove, dial onion over Tor, tunnel
scripts/probe.mjs Adversary probe (no-proof / garbage / forged-group)
scripts/run-all.sh Start tor + gateway + shim
scripts/stop.sh Stop everything this PoC started
tor/torrc Dedicated tor: ports 9250/9251, onion service, PoW note
// The client shim: a local HTTP CONNECT proxy that adds a reputation proof and
// routes to the gateway onion over Tor.
//
// Point any tool at it: http_proxy=http://127.0.0.1:8888 https_proxy=...
// or curl -x http://127.0.0.1:8888 https://example.com
//
// For each CONNECT host:port it:
// 1. generates (and caches per epoch) a Semaphore membership proof,
// 2. dials gateway.onion:80 via the Tor SOCKS port (no exit node in path),
// 3. sends the JSON envelope { v, target, proof },
// 4. on gateway "ok", tells the local client "200 Connection established" and
// pipes the bytes; the local client then does TLS straight to the target.
//
// The shim never sees plaintext either: it just bridges the local socket to the
// Tor stream. Tor handles the anonymity and the addressing; this shim is the
// thin app-layer protocol that carries the reputation proof, which Tor itself has no
// place to carry.
import http from "node:http";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { SocksClient } from "socks";
import { proveMembership, currentEpoch } from "../lib/semaphore.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const LISTEN_PORT = Number(process.env.RGOE_SHIM_PORT || 8888);
const TOR_SOCKS_HOST = process.env.RGOE_TOR_HOST || "127.0.0.1";
const TOR_SOCKS_PORT = Number(process.env.RGOE_TOR_PORT || 9250);
const SECRET = process.env.RGOE_SECRET;
if (!SECRET) {
console.error("set RGOE_SECRET (from `npm run enroll`) before starting the shim");
process.exit(1);
}
async function gatewayOnion() {
if (process.env.RGOE_ONION) return process.env.RGOE_ONION.replace(/\.onion$/, "");
const host = (await readFile(join(HERE, "..", "tor", "hs", "hostname"), "utf8")).trim();
return host.replace(/\.onion$/, "");
}
// Cache one proof per epoch. Within an epoch a member's nullifier is constant, so
// the same proof is valid for every request and the gateway counts each
// redemption against the rate budget. Proving is the only slow step (~1-2s); we
// pay it once per hour, not per request.
let cache = { epoch: null, proof: null, inflight: null };
async function getProof() {
const epoch = currentEpoch();
if (cache.epoch === epoch && cache.proof) return cache.proof;
if (cache.inflight && cache.inflightEpoch === epoch) return cache.inflight;
cache.inflightEpoch = epoch;
cache.inflight = (async () => {
const proof = await proveMembership(SECRET, epoch);
cache = { epoch, proof, inflight: null };
return proof;
})();
return cache.inflight;
}
// Dial the gateway onion via Tor SOCKS, retrying through cold start. Right after
// tor (re)starts, the onion descriptor takes a little while to propagate to the
// hashring before the first rendezvous can complete, so the first dial can time
// out. Retry a few times with a generous timeout before giving up.
async function dialOnion(onion, attempts = 4) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try {
const { socket } = await SocksClient.createConnection({
proxy: { host: TOR_SOCKS_HOST, port: TOR_SOCKS_PORT, type: 5 },
command: "connect",
destination: { host: onion + ".onion", port: 80 },
timeout: 120000,
});
return socket;
} catch (e) {
lastErr = e;
if (i < attempts - 1) {
console.log(` onion not ready (${e.message}), retry ${i + 1}/${attempts - 1}`);
await new Promise((r) => setTimeout(r, 3000));
}
}
}
throw lastErr;
}
function readLine(socket) {
return new Promise((resolve, reject) => {
let buf = Buffer.alloc(0);
const onData = (chunk) => {
buf = Buffer.concat([buf, chunk]);
const nl = buf.indexOf(0x0a);
if (nl === -1) return;
socket.removeListener("data", onData);
socket.removeListener("error", reject);
resolve({ line: buf.subarray(0, nl).toString("utf8"), rest: buf.subarray(nl + 1) });
};
socket.on("data", onData);
socket.once("error", reject);
});
}
const server = http.createServer();
server.on("connect", async (req, clientSocket, head) => {
const target = req.url; // "host:port"
try {
const onion = await gatewayOnion();
const proof = await getProof();
const torSocket = await dialOnion(onion);
torSocket.setNoDelay(true);
torSocket.write(JSON.stringify({ v: 1, target, proof }) + "\n");
const { line, rest } = await readLine(torSocket);
const ack = JSON.parse(line);
if (!ack.ok) {
clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\n\r\ngate refused: ${ack.err}\n`);
clientSocket.destroy();
torSocket.destroy();
console.log(`REFUSED ${target} (${ack.err})`);
return;
}
clientSocket.write("HTTP/1.1 200 Connection established\r\n\r\n");
if (rest && rest.length) clientSocket.write(rest); // unlikely, but be safe
if (head && head.length) torSocket.write(head);
clientSocket.pipe(torSocket);
torSocket.pipe(clientSocket);
clientSocket.on("error", () => torSocket.destroy());
torSocket.on("error", () => clientSocket.destroy());
console.log(`TUNNEL ${target} via ${onion.slice(0, 16)}..onion`);
} catch (e) {
try { clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\n\r\nshim error: ${e.message}\n`); } catch {}
clientSocket.destroy();
console.log(`ERROR ${target} ${e.message}`);
}
});
server.listen(LISTEN_PORT, "127.0.0.1", () => {
console.log(`shim up: http://127.0.0.1:${LISTEN_PORT} -> Tor SOCKS ${TOR_SOCKS_HOST}:${TOR_SOCKS_PORT} -> gateway.onion`);
console.log(`use: curl -x http://127.0.0.1:${LISTEN_PORT} https://api.ipify.org?format=json`);
});
// The gateway: a reputation-gated egress proxy, published as a Tor onion service.
//
// It listens on 127.0.0.1:8443 (Tor maps <addr>.onion:80 -> here). For each
// incoming connection it:
// 1. reads a single newline-terminated JSON envelope { v, target, proof },
// 2. verifies the zk proof: valid + against OUR reputation set + current epoch,
// 3. enforces a per-nullifier rate budget for the epoch (the RLN rate limit),
// 4. on success: opens a raw TCP tunnel to `target` from THIS host's IP and
// pipes bytes both ways (TLS stays end-to-end client<->target; the gateway
// sees only the host:port, never the plaintext),
// 5. on any failure: writes a short error envelope and drops the connection.
//
// The destination site sees the gateway's IP, not a Tor exit and not the client.
// Because only rate-limited, in-set members can egress, the gateway's IP stays
// clean instead of degrading into a blocklisted open proxy. That is the whole
// point: the reputation gate is what manufactures the scarcity that keeps an IP
// reputable, without ever identifying the user.
import net from "node:net";
import { loadGroup, checkProof, currentEpoch, EPOCH_SECONDS } from "../lib/semaphore.mjs";
const LISTEN_HOST = "127.0.0.1";
const LISTEN_PORT = 8443;
const RATE_LIMIT = Number(process.env.RGOE_RATE_LIMIT || 30); // redemptions / member / epoch
const MAX_ENVELOPE = 64 * 1024;
// Per-epoch nullifier -> count. Anonymous: we never learn which member a
// nullifier belongs to, only that "this anonymous member has spent N this hour".
// Old epochs are swept so memory stays bounded.
const budget = new Map(); // scope(string) -> Map<nullifier, count>
function spend(scope, nullifier) {
let perScope = budget.get(scope);
if (!perScope) {
perScope = new Map();
budget.set(scope, perScope);
}
const used = perScope.get(nullifier) || 0;
if (used >= RATE_LIMIT) return { ok: false, used };
perScope.set(nullifier, used + 1);
return { ok: true, used: used + 1 };
}
function sweepEpochs() {
const keep = new Set([String(currentEpoch()), String(currentEpoch() - 1n)]);
for (const scope of budget.keys()) if (!keep.has(scope)) budget.delete(scope);
}
setInterval(sweepEpochs, EPOCH_SECONDS * 1000).unref();
function readEnvelope(socket) {
return new Promise((resolve, reject) => {
let buf = Buffer.alloc(0);
const onData = (chunk) => {
buf = Buffer.concat([buf, chunk]);
const nl = buf.indexOf(0x0a);
if (nl === -1) {
if (buf.length > MAX_ENVELOPE) cleanup(new Error("envelope too large"));
return;
}
const line = buf.subarray(0, nl).toString("utf8");
const rest = buf.subarray(nl + 1); // any early bytes after the envelope
cleanup(null, line, rest);
};
const onErr = (e) => cleanup(e);
const onEnd = () => cleanup(new Error("closed before envelope"));
const timer = setTimeout(() => cleanup(new Error("envelope timeout")), 30000);
function cleanup(err, line, rest) {
clearTimeout(timer);
socket.removeListener("data", onData);
socket.removeListener("error", onErr);
socket.removeListener("end", onEnd);
if (err) reject(err);
else resolve({ line, rest });
}
socket.on("data", onData);
socket.on("error", onErr);
socket.on("end", onEnd);
});
}
function reply(socket, obj) {
try { socket.write(JSON.stringify(obj) + "\n"); } catch {}
}
function validTarget(target) {
if (typeof target !== "string") return null;
const m = target.match(/^([a-zA-Z0-9.\-]+):(\d{1,5})$/);
if (!m) return null;
const port = Number(m[2]);
if (port < 1 || port > 65535) return null;
// PoC egress policy: TLS ports only, so the gateway is a metadata-only tunnel
// and never sees plaintext. Widen deliberately if you want a forward proxy.
if (port !== 443) return null;
return { host: m[1], port };
}
let TRUSTED_ROOT = null;
async function handle(socket) {
socket.setNoDelay(true);
let env;
try {
const { line, rest } = await readEnvelope(socket);
env = JSON.parse(line);
env.__rest = rest;
} catch (e) {
reply(socket, { ok: false, err: "bad-envelope:" + e.message });
return socket.destroy();
}
const check = await checkProof(env.proof, TRUSTED_ROOT);
if (!check.ok) {
console.log(`DROP ${check.reason} target=${env.target}`);
reply(socket, { ok: false, err: "gate:" + check.reason });
return socket.destroy();
}
const tgt = validTarget(env.target);
if (!tgt) {
reply(socket, { ok: false, err: "bad-target" });
return socket.destroy();
}
const spent = spend(check.scope, check.nullifier);
if (!spent.ok) {
console.log(`DROP rate-limited null=${check.nullifier.slice(0, 10)}.. used=${spent.used}`);
reply(socket, { ok: false, err: "rate-limited" });
return socket.destroy();
}
const upstream = net.connect(tgt.port, tgt.host, () => {
console.log(`PASS egress->${tgt.host}:${tgt.port} null=${check.nullifier.slice(0, 10)}.. ${spent.used}/${RATE_LIMIT} epoch=${check.scope}`);
reply(socket, { ok: true });
if (env.__rest && env.__rest.length) upstream.write(env.__rest);
socket.pipe(upstream);
upstream.pipe(socket);
});
upstream.setNoDelay(true);
upstream.on("error", (e) => {
reply(socket, { ok: false, err: "upstream:" + e.code });
socket.destroy();
});
socket.on("error", () => upstream.destroy());
}
async function main() {
const { root, count } = await loadGroup();
TRUSTED_ROOT = root;
const server = net.createServer(handle);
server.listen(LISTEN_PORT, LISTEN_HOST, () => {
console.log(`gateway up on ${LISTEN_HOST}:${LISTEN_PORT}`);
console.log(`reputation set: ${count} members, root ${root.slice(0, 18)}..`);
console.log(`rate budget: ${RATE_LIMIT} redemptions / member / ${EPOCH_SECONDS}s epoch`);
console.log(`egress policy: :443 only (metadata-only TLS tunnel)`);
});
}
main().catch((e) => { console.error(e); process.exit(1); });
// Grant reputation to a member: add them to the reputation set.
//
// This is the trust boundary of the whole system: the zk proof gates on
// membership in THIS set, so whatever ceremony adds a commitment here is what
// "reputable" means. For the PoC, enrollment is a local command. In production
// the leaf is added only after whatever the admission policy requires: a stake,
// an invite, accrued good standing, or a proof-of-personhood check (World ID,
// which is itself Semaphore-based). The cryptography below is identical either
// way; only the admission policy changes.
//
// Usage:
// node group/enroll.mjs -> new identity, prints a client secret
// node group/enroll.mjs <name> -> label the member (label is NOT stored in the set)
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { Identity, MEMBERS_PATH, loadGroup } from "../lib/semaphore.mjs";
const label = process.argv[2] || "member-" + randomBytes(2).toString("hex");
// A random high-entropy secret. The holder of this secret can prove membership.
const secret = randomBytes(32).toString("hex");
const identity = new Identity(secret);
const commitment = identity.commitment.toString();
let doc = { version: 1, members: [] };
if (existsSync(MEMBERS_PATH)) {
doc = JSON.parse(await readFile(MEMBERS_PATH, "utf8"));
}
doc.members.push(commitment);
await writeFile(MEMBERS_PATH, JSON.stringify(doc, null, 2) + "\n");
const { root, count } = await loadGroup();
console.log("Granted reputation to a member (added to the reputation set).");
console.log(" label: " + label + " (local only, never enters the set)");
console.log(" commitment: " + commitment);
console.log(" set size: " + count);
console.log(" group root: " + root);
console.log("");
console.log("Give this secret to the client (keep it private):");
console.log("");
console.log(" export RGOE_SECRET=" + secret);
console.log("");
console.log("The client proves membership with this secret; it never sends the secret itself.");
// Shared Semaphore helpers used by the enroll tool, the client shim, and the gateway.
//
// The "reputation set" is a Semaphore group: a Merkle tree of identity commitments.
// A client proves, in zero knowledge, that it owns the secret behind *some* leaf
// in that tree, without revealing which one. The proof carries a `nullifier`
// derived from (secret, scope). We use scope = the current epoch, so:
// - within an epoch a given member always produces the SAME nullifier
// => the gateway can rate-limit per member without knowing who they are,
// - across epochs the nullifier changes => requests are unlinkable over time.
// That is the RLN (rate-limiting nullifier) idea at PoC fidelity.
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { Identity } from "@semaphore-protocol/identity";
import { Group } from "@semaphore-protocol/group";
import { generateProof, verifyProof } from "@semaphore-protocol/proof";
const HERE = dirname(fileURLToPath(import.meta.url));
export const MEMBERS_PATH = join(HERE, "..", "group", "members.json");
// One epoch = one rate-limit window. 3600s = a member gets a fresh budget hourly.
export const EPOCH_SECONDS = 3600;
// A constant signal. In production you would bind this to the request so a proof
// cannot be lifted and replayed against a different target; for the PoC the proof
// rides inside the Tor-encrypted onion tunnel, so it is never exposed to replay.
export const MESSAGE = 1n;
export function currentEpoch(nowMs = Date.now()) {
return BigInt(Math.floor(nowMs / 1000 / EPOCH_SECONDS));
}
// Load the published reputation set. Returns { group, root, count }.
export async function loadGroup() {
const raw = JSON.parse(await readFile(MEMBERS_PATH, "utf8"));
const commitments = raw.members.map((m) => BigInt(m));
const group = new Group(commitments);
return { group, root: group.root.toString(), count: commitments.length };
}
// Client side: prove membership for the current epoch.
export async function proveMembership(secret, scope = currentEpoch()) {
const identity = new Identity(secret);
const { group } = await loadGroup();
const proof = await generateProof(identity, group, MESSAGE, scope);
return proof; // JSON-serializable: { merkleTreeRoot, nullifier, scope, message, points, merkleTreeDepth }
}
// Gateway side: is this a valid, in-set, current-epoch proof?
// Returns { ok, reason, nullifier, scope }.
export async function checkProof(proof, trustedRoot, nowMs = Date.now()) {
if (!proof || typeof proof !== "object") return { ok: false, reason: "no-proof" };
// 1. The zk proof must verify.
let valid = false;
try {
valid = await verifyProof(proof);
} catch (e) {
return { ok: false, reason: "verify-threw:" + e.message };
}
if (!valid) return { ok: false, reason: "invalid-proof" };
// 2. It must be a proof against OUR reputation set, not a set the client invented.
if (String(proof.merkleTreeRoot) !== String(trustedRoot)) {
return { ok: false, reason: "wrong-group-root" };
}
// 3. Scope must be the current epoch (allow one epoch of clock skew).
const now = currentEpoch(nowMs);
const scope = BigInt(proof.scope);
if (scope !== now && scope !== now - 1n) {
return { ok: false, reason: "stale-epoch" };
}
return { ok: true, nullifier: String(proof.nullifier), scope: String(scope) };
}
export { Identity, Group, generateProof, verifyProof };
{
"name": "reputation-gated-onion-egress",
"version": "0.1.0",
"description": "A Tor onion-service egress proxy gated by a zero-knowledge proof of membership in a reputation set (Semaphore + epoch-scoped nullifiers = RLN-style anonymous rate limiting).",
"type": "module",
"private": true,
"scripts": {
"enroll": "node group/enroll.mjs",
"gateway": "node gateway/gateway.mjs",
"shim": "node client/shim.mjs"
},
"dependencies": {
"@semaphore-protocol/group": "^4.0.0",
"@semaphore-protocol/identity": "^4.0.0",
"@semaphore-protocol/proof": "^4.0.0",
"socks": "^2.8.3"
}
}
// Adversary probe: connect straight to the gateway onion over Tor and try to
// egress with a bad/forged/missing proof. Confirms the gate drops non-members.
//
// node scripts/probe.mjs noproof -> envelope with no proof
// node scripts/probe.mjs garbage -> structurally bogus proof
// node scripts/probe.mjs wronggroup -> a VALID Semaphore proof, but for an
// attacker-controlled group (the real
// attack: prove membership in a set you
// made up). Gateway must reject on root.
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { SocksClient } from "socks";
import { Identity } from "@semaphore-protocol/identity";
import { Group } from "@semaphore-protocol/group";
import { generateProof } from "@semaphore-protocol/proof";
import { currentEpoch } from "../lib/semaphore.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const mode = process.argv[2] || "noproof";
const onion = (await readFile(join(HERE, "..", "tor", "hs", "hostname"), "utf8")).trim();
let proof = null;
if (mode === "garbage") {
proof = { merkleTreeRoot: "123", nullifier: "1", scope: String(currentEpoch()), message: "1", merkleTreeDepth: 1, points: Array(8).fill("1") };
} else if (mode === "wronggroup") {
// Attacker forges a perfectly valid proof against a group they invented.
const attacker = new Identity("attacker-not-enrolled");
const fakeGroup = new Group([attacker.commitment]);
proof = await generateProof(attacker, fakeGroup, 1n, currentEpoch());
console.log("attacker built a VALID proof, root", String(proof.merkleTreeRoot).slice(0, 18) + "..");
}
const { socket } = await SocksClient.createConnection({
proxy: { host: "127.0.0.1", port: 9250, type: 5 },
command: "connect",
destination: { host: onion, port: 80 },
timeout: 60000,
});
socket.write(JSON.stringify({ v: 1, target: "api.ipify.org:443", proof }) + "\n");
let buf = "";
socket.on("data", (d) => {
buf += d.toString();
if (buf.includes("\n")) {
console.log(`mode=${mode} gateway replied: ${buf.trim()}`);
socket.destroy();
process.exit(0);
}
});
socket.on("error", (e) => { console.log("socket error", e.message); process.exit(1); });
setTimeout(() => { console.log("timeout, no reply"); process.exit(1); }, 30000);
# Tor instance dedicated to the reputation-gated egress PoC.
# Runs on its own ports so it never collides with any other tor on this machine
# (e.g. the searxng-tor-bench container on 9050/9051).
SocksPort 9250
ControlPort 9251
CookieAuthentication 1
# Keep all state inside the project so the PoC is self-contained and the onion
# address is stable across restarts (the key lives under hs/).
DataDirectory ./tor/data
# Publish the gateway as a v3 onion service. The gateway process listens on
# 127.0.0.1:8443; Tor exposes it at <addr>.onion:80. There is NO exit node in
# the client -> gateway path: the connection is rendezvous-routed and stays
# inside Tor, so the client IP is never revealed to the gateway and the gateway
# is not subject to Tor-exit IP reputation.
HiddenServiceDir ./tor/hs
HiddenServicePort 80 127.0.0.1:8443
# Anti-DoS tier: Tor's built-in onion-service proof-of-work defense. This makes
# flooding the rendezvous (before any zk verification work) costly. It is the
# cheap outer gate; the reputation proof is the expensive inner gate.
#
# NOTE: requires a tor built with the 'pow' module. The Homebrew bottle does not
# include it, so it is commented out here. To enable, install a tor with pow
# (e.g. build with --enable-gpl, or use the Tor Browser bundle's tor) and
# uncomment the next line. The system is fully functional without it.
# HiddenServicePoWDefensesEnabled 1
Log notice stdout
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment