Skip to content

Instantly share code, notes, and snippets.

@arminsabouri
Last active September 7, 2025 15:33
Show Gist options
  • Select an option

  • Save arminsabouri/1d41501b4e5caa38870097f70ec0e604 to your computer and use it in GitHub Desktop.

Select an option

Save arminsabouri/1d41501b4e5caa38870097f70ec0e604 to your computer and use it in GitHub Desktop.
Dev notes: Privacy Preserving Nostr + BIP-77

Privacy preserving Nostr Relays and BIP-77

Nostr is a pub/sub protocol: clients publish signed events (notes) and subscribe to other's events using filters. Relays are lean store-and-forward servers that persist and index events and push them to connected subscribers. When a client connects to a relay, it reveals its IP address, enabling metadata correlation.

How can we:

  1. Prevent a relay from learning a client’s network identity while they interact with Nostr.
  2. Enable interactive protocols to message over Nostr s.t the messages are indistinguishable from ordinary nostr noise.

Nostr Relays as OHTTP Gateways

Original Idea from nothingmuch

One minimally invasive approach is to configure a Nostr relay as an OHTTP target. In this design, relays would publish an HPKE key configuration at the designated endpoint. Clients fetch this configuration, use the relay’s public key, and encapsulate their requests so that only the relay can decrypt them. Messages are routed through an OHTTP relay. Unlike other tunneling schemes, this model does not forward raw TCP WebSocket traffic, and that is a strength rather than a limitation. Each request is encapsulated with a fresh ephemeral keypair, making requests unlinkable and indistinguishable from one another. Rate limiting and an authentication schemes can be layered on top using anonymous credential schemes -- but this remains an open research direction.

In practice, this shifts the Nostr relay into the role of a server in a conventional client–server exchange, where every event post or subscription request is a discrete, encrypted message. NIP-01 semantics remain the same, but they are mapped to a stateless request/response REST-like pattern instead of a long-lived WebSocket session.

The OHTTP requirement on clients is minimal. clients should be equipped with the cryptographic primitives to support the OHTTP HPKE (Sha256, ChaCha20Poly1305, Secp256k1).

An example client implementation:

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();

    let ephemeral_key = Keys::generate();
    let target = "http://localhost:8080".to_string();
    let ohttp_relay = "http://localhost:3000".to_string();
    // First fetch the key configuration from the relay
    let mut key_config = fetch_ohttp_keys(ohttp_relay.clone(), target.clone()).await?;
    let client = reqwest::Client::new();
    println!("{key_config:#?}");

    // Now we can post a text note and encapsulate it with the key configuration
    let now = Timestamp::now();
    let event = EventBuilder::text_note(format!("foooo baar over OHTTP ! {now}"))
        .sign_with_keys(&ephemeral_key)
        .unwrap();
    let client_message = ClientMessage::Event(Cow::Borrowed(&event)).as_json();

    let (encapsulated, ohttp_ctx) = ohttp_encapsulate(
        &mut key_config.0,
        "POST",
        &target,
        Some(client_message.as_bytes()),
    )?;

    let response = client
        .post(ohttp_relay.clone())
        .header("Content-Type", "message/ohttp-req")
        .body(encapsulated.to_vec())
        .send()
        .await?;
    // Test if we can decrypt the response -- which should be empty
    let response_body = response.bytes().await?;
    let decapsulated = ohttp_ctx.decapsulate(&response_body)?;
    let str_res = String::from_utf8(decapsulated)?;
    println!("{str_res:#?}");

    // Now we can make another request to get the note we posted. Also encapsulated and routed through the OHTTP relay.

    let pk = ephemeral_key.public_key;
    let filter = Filter::new().author(pk).kind(Kind::TextNote);
    let subscription_id = SubscriptionId::generate();

    let client_message = ClientMessage::Req {
        subscription_id: Cow::Borrowed(&subscription_id),
        filter: Cow::Borrowed(&filter),
    }
    .as_json();
    let (encapsulated, ohttp_ctx) = ohttp_encapsulate(
        &mut key_config.0,
        "GET",
        &target,
        Some(client_message.as_bytes()),
    )?;

    let response = client
        .post(ohttp_relay)
        .header("Content-Type", "message/ohttp-req")
        .body(encapsulated.to_vec())
        .send()
        .await?;
    println!("{response:#?}");

    let response_body = response.bytes().await?;

    let decapsulated = ohttp_ctx.decapsulate(&response_body)?;
    // Decapsulated is a new line delimited list of nostr events.
    let str_res = String::from_utf8(decapsulated)?;
    let events = str_res.split("\n").map(|s| Event::from_json(s)).filter_map(Result::ok).collect::<Vec<Event>>();
    println!("{events:#?}");
    assert_eq!(events[0], event);

    Ok(())
}

You can find the complete code here.

Note that the bhttp encapsulated messages use POST for sending requests and GET for "fetching" to events.

Case study: BIP-77 over nostr

Why? Nostr already has a wide base of relays that support a various bitcoin applications. We can leverage this infrastructure and remove the need to store encrypted and plaintext data on a dedicated server which can be a liability for the project. Note that this problem can be solved via various transports. I pick nostr because of my familiarity with the protocol and its tools.

image

Our contraints are the following:

  • Attributability: is the client's metadata attributed to the nostr note?
  • Indistinguishability: Do BIP-77 message notes blend in with other notes?
  • Backwards compatibility: Can we achieve our goals with out breaking the BIP-77 wire protocol?

In order the keep the changes to BIP-77 minimal we can translate BIP-77 messages to nostr notes via a nostr-compatibility OHTTP gateway bridge (NCB) and forward them to the target relay which can either support the RESTful interface discussed above. Or fallback to native websocket protocol incase the node does not support this capability. The nostr-compatibility bridge can function as the existing directory as defined in BIP-77 and can be configurable.

The BIP-77 messages are then Giftwrapped and forwarded to the target nostr relay. The NCB encrypts the internal note (rumor in NIP-59 parlance) with a dervived key S.t only the NCB can decrypt it. Key derivation is done via HKDF-SHA256. Where we first extract the prk using a long-lived key as salt and the BIP-77 short id as IKM. And then we expand it to a 32-byte key and add some domain separation string.

POC of the NCB can be found here as well as modifications to nostr-rs to support the OHTTP gateway.

Next steps is to write a NIP which standardizes nostr relays as OHTTP targets and how clients can interface with their destiantion relays via the OHTTP proxy.

Appendix:

Random other ideas before we landed on nostr relays as OHTTP.

Nostr-Native Oblivious Nostr Relay (ONR)

One proposal is to introduce an Oblivious Nostr Relay (ONR), modeled after the OHTTP gateway pattern. In this design, an ONR acts as a gateway that forwards encrypted requests to a target relay without revealing the client’s IP address. The ONR would advertise its public key in its NIP-11 capabilities, and clients would use that key to encrypt requests before encapsulating them in a giftwrap event. That event specifies the intended target relay, but the payload itself is opaque to the ONR, which simply forwards it. The target relay can decrypt and process the request, but cannot trace it back to the originating client.

This approach works reasonably well for publishing events, since posting requires only a one-shot message encapsulated and forwarded by the ONR. Subscriptions, however, present a complication. Because ONRs are stateless and do not maintain open connections on behalf of clients, continuous streaming delivery will not work. You could always maintain a tcp websocket connection but then each request is correlated to a singular session. Since Nostr already has a wide base of relays, an ONR could be deployed incrementally, providing some privacy improvements while preserving interoperability with current infrastructure.

Here are some projects and suggestions do something similar:

MASQUE tunneling

Useful links:

A more disruptive option would be to migrate Nostr from TCP WebSockets to QUIC and WebTransport. In this model, relays and clients would communicate over bidirectional QUIC streams rather than traditional WebSockets. The immediate benefit is compatibility with existing MASQUE infrastructure, which already supports multi-proxy routing schemes. This creates an opportunity to reuse an ecosystem of standardized, high-performance transport proxies rather than inventing bespoke mechanisms for Nostr.

At a high level, a client would initiate a connection by issuing an extended CONNECT request to a MASQUE proxy, designating the target host and port. The proxy resolves DNS if needed, opens a UDP socket, and forwards encrypted QUIC packets between the client and the target. From the perspective of the Nostr protocol itself, little changes. NIP-01 event semantics remain intact; the only modification is at the transport layer. This change could be made backwards compatible by allowing relays to advertise WebTransport support in their NIP-11 metadata. Migration also enables multiplexing, so a client can maintain a single QUIC connection and run multiple independent subscriptions as parallel streams rather than opening separate WebSocket connections. However, multiplexing multiple events/subs in a single connection is as well as stateful connections most likely also a metadata leak.

There is also a more conservative alternative: tunneling existing WebSocket-over-TCP traffic through MASQUE rather than migrating to QUIC. MASQUE supports CONNECT-TCP, which allows a proxy to tunnel arbitrary TCP byte streams. A client could encapsulate a standard wss:// session inside such a tunnel. From the relay’s point of view nothing changes: it still sees a WebSocket connection, but the apparent source IP is the proxy’s. This approach preserves compatibility with current relays and avoids mandating QUIC or TLS 1.3 at the transport layer. However, relying on MASQUE to forward stateful connections raises its own privacy considerations, since multiplexing and long-lived state can reveal correlatable metadata.

Note: that if nostr migrated to webtransport / Quic TLS 1.3 would be a first class dependency on the network which is not true today. Nostr is a protocol free from "centralized" dependency. PKI may be a downside here where this is not necessarily true for an http/2 OHTTP proxies. Perhaps this can be ignored and you can use self-signed certificates. Remains an open question.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment