Skip to content

Auto-TLS support for py-libp2p#1072

Merged
seetadev merged 19 commits intolibp2p:mainfrom
lla-dane:autotls
Feb 2, 2026
Merged

Auto-TLS support for py-libp2p#1072
seetadev merged 19 commits intolibp2p:mainfrom
lla-dane:autotls

Conversation

@lla-dane
Copy link
Copy Markdown
Contributor

@lla-dane lla-dane commented Dec 1, 2025

Aims to resolve #555

This PR introduces Auto-TLS support for py-libp2p, alligned with the libp2p Auto-TLS client specification. The goal is to allow libp2p nodes to automatically obtain and manage CA (ACME) authorised TLS certificates, without requiring self-signed certificates.

Importance of Auto-TLS

In many environments that rely on TLS -- most notably the browser ecosystem and standard HTTPS stacks -- self-signed certificates are not accepted by defualt. Browsers, reverse proxies, and many client libraries enfore WeB PKI validation rules that require certificates to chain back to a trusted Certificate Authority. As a result, self-signed certificates often require custom trust configuration or rejected outright.

By usign CA-authorized certificates, py-libp2p transports can operate within these existing constraints instead of working around them.

Implementation

At a high level, this work wires together:

  • ACME account and order management.
  • DNS-01 challenge handling via the libp2p.direct namespace.
  • PeerID-based domain binding
  • Certificate issuance and retrieval suitable for securing libp2p transports.

The implementation closely follows the Auto-TLS client spec and the peer-id-auth flow descibed in the libp2p specs, and is intented to be a foundation that transports. (eg. QUIC / TLS / WebTransport) can build on.

References:
https://github.com/libp2p/specs/blob/master/tls/autotls-client.md
https://github.com/libp2p/specs/blob/master/http/peer-id-auth.md
https://blog.libp2p.io/autotls/

@lla-dane
Copy link
Copy Markdown
Contributor Author

lla-dane commented Dec 4, 2025

So presently the auto-tls code is running correctly upto fetching the DNS-01 challenge from the ACME servers, and completing the peer-id authentication with AUTO-TLS broker (registration.libp2p.direct). After that there is an error coming up when the AUTO-TLS Broker tries to dial-in our node. The logs are like this:

image

As seen in the last lines of these logs the inbound connection is failing.
So I did a tcp-dump to see what was going on, basically to see the multistream-select handshakes coming from the broker node. They were like this:

image

as seen from this particular packet-log:

18:07:50.937274 ens5  In  IP (tos 0x0, ttl 57, id 48811, offset 0, flags [DF], proto TCP (6), length 84)
    ec2-18-188-47-119.us-east-2.compute.amazonaws.com.43378 > ip-172-31-35-71.ap-south-1.compute.internal.9000: Flags [P.], cksum 0xb207 (correct), seq 1:33, ack 1, win 71, options [nop,nop,TS val 768382321 ecr 3436111510], length 32
	0x0000:  4500 0054 beab 4000 3906 715f 12bc 2f77  E..T..@.9.q_../w
	0x0010:  ac1f 2347 a972 2328 d891 3c5e 57f2 4f65  ..#G.r#(..<^W.Oe
	0x0020:  8018 0047 b207 0000 0101 080a 2dcc 9571  ...G........-..q
	0x0030:  ccce e696 132f 6d75 6c74 6973 7472 6561  ...../multistrea
	0x0040:  6d2f 312e 302e 300a 0b2f 746c 732f 312e  m/1.0.0../tls/1.
	0x0050:  302e 300a                                0.0. 

The inbound connection, was negotiation for /tls/1.0.0 instead of /noise. So I guess this is why the connection was rejected.

@seetadev

@seetadev
Copy link
Copy Markdown
Contributor

seetadev commented Dec 8, 2025

So presently the auto-tls code is running correctly upto fetching the DNS-01 challenge from the ACME servers, and completing the peer-id authentication with AUTO-TLS broker (registration.libp2p.direct). After that there is an error coming up when the AUTO-TLS Broker tries to dial-in our node. The logs are like this:

image As seen in the last lines of these logs the inbound connection is failing. So I did a tcp-dump to see what was going on, basically to see the multistream-select handshakes coming from the broker node. They were like this: image as seen from this particular packet-log:
18:07:50.937274 ens5  In  IP (tos 0x0, ttl 57, id 48811, offset 0, flags [DF], proto TCP (6), length 84)
    ec2-18-188-47-119.us-east-2.compute.amazonaws.com.43378 > ip-172-31-35-71.ap-south-1.compute.internal.9000: Flags [P.], cksum 0xb207 (correct), seq 1:33, ack 1, win 71, options [nop,nop,TS val 768382321 ecr 3436111510], length 32
	0x0000:  4500 0054 beab 4000 3906 715f 12bc 2f77  E..T..@.9.q_../w
	0x0010:  ac1f 2347 a972 2328 d891 3c5e 57f2 4f65  ..#G.r#(..<^W.Oe
	0x0020:  8018 0047 b207 0000 0101 080a 2dcc 9571  ...G........-..q
	0x0030:  ccce e696 132f 6d75 6c74 6973 7472 6561  ...../multistrea
	0x0040:  6d2f 312e 302e 300a 0b2f 746c 732f 312e  m/1.0.0../tls/1.
	0x0050:  302e 300a                                0.0. 

The inbound connection, was negotiation for /tls/1.0.0 instead of /noise. So I guess this is why the connection was rejected.

@seetadev

@lla-dane : Hi Abhinav. Fantastic progress on autotls module.

Thank you so much for sharing the details. Appreciate it.

Wish to ask if you found the fix in trio.py.

Please also resolve the ci/cd issues whenever you get a chance.

acul71 added a commit that referenced this pull request Dec 13, 2025
- Enhanced get_remote_address() in TrioTCPStream with address caching
  and defensive checks to handle socket state transitions gracefully
- Fixed Ed25519PublicKey initialization to use from_bytes() method
- Added proper type annotation for server_id: ID | None
- Added None check for hostname before passing to ClientInitiatedHandshake
- Removed unused variables (commented with explanations for future use)
- Removed dead code (unused function calls with hardcoded port)
- Removed debug print statements in favor of proper logging
- Fixed code formatting, import ordering, and line length violations

This resolves the get_remote_address() exception that was occurring
when the Auto-TLS broker dials back into the node.

Fixes issue reported in PR #1072 comments.
@acul71
Copy link
Copy Markdown
Contributor

acul71 commented Dec 13, 2025

Fixes for Auto-TLS PR #1072

This commit addresses the get_remote_address() exception and resolves all linting/type checking issues.

🔧 Main Fix: Enhanced get_remote_address() Implementation

Problem: When the Auto-TLS broker dialed back, get_remote_address() was throwing exceptions, causing connection failures.

Solution: Enhanced the method with address caching, defensive checks, and improved error handling.

Key Changes in libp2p/io/trio.py:

# Added caching to handle socket state transitions
_cached_remote_address: tuple[str, int] | None

def get_remote_address(self) -> tuple[str, int] | None:
    # Return cached value if available
    if self._cached_remote_address is not None:
        return self._cached_remote_address
    
    # Defensive checks before accessing socket
    if not hasattr(self.stream, "socket"):
        logger.debug("SocketStream has no 'socket' attribute")
        return None
    
    socket = self.stream.socket
    if socket is None:
        logger.debug("Socket is None")
        return None
    
    # Get and cache remote address
    remote_addr = socket.getpeername()
    # ... validation and caching logic ...

🐛 Type Error Fixes

  1. Ed25519PublicKey initialization - Use from_bytes() instead of direct constructor
  2. server_id type annotation - Added self.server_id: ID | None = None
  3. hostname None check - Added validation before passing to ClientInitiatedHandshake

🧹 Code Quality

  • Removed unused variables (commented with explanations)
  • Removed dead code (get_available_interfaces(8000) calls)
  • Removed debug print() statements
  • Fixed code formatting and import ordering

✅ All Checks Pass

  • ✅ Linting: All ruff checks pass
  • ✅ Type checking: All pyrefly errors resolved
  • ✅ Tests: All 1744 tests pass
  • ✅ Pre-commit hooks: All pass

This should resolve the broker dial-back issue. Ready for testing! 🚀

Comment on lines 51 to 105
async def negotiate(
self,
communicator: IMultiselectCommunicator,
negotiate_timeout: int = DEFAULT_NEGOTIATE_TIMEOUT,
) -> tuple[TProtocol | None, StreamHandlerFn | None]:
"""
Negotiate performs protocol selection.

:param stream: stream to negotiate on
:param negotiate_timeout: timeout for negotiation
:return: selected protocol name, handler function
:raise MultiselectError: raised when negotiation failed
"""
try:
with trio.fail_after(negotiate_timeout):
await self.handshake(communicator)

while True:
try:
print("\nNEGOTIATE LOOP")
command = await communicator.read()
print("COMMAND: ", command)
except MultiselectCommunicatorError as error:
print("ERROR IN NEGOTIATE READ")
raise MultiselectError() from error

if command == "ls":
supported_protocols = [
p for p in self.handlers.keys() if p is not None
]
response = "\n".join(supported_protocols) + "\n"

try:
await communicator.write(response)
except MultiselectCommunicatorError as error:
raise MultiselectError() from error

else:
protocol_to_check = None if not command else TProtocol(command)
if protocol_to_check in self.handlers:
try:
await communicator.write(command)
except MultiselectCommunicatorError as error:
raise MultiselectError() from error

return protocol_to_check, self.handlers[protocol_to_check]
try:
await communicator.write(PROTOCOL_NOT_FOUND_MSG)
print("PROTOCOL NOT IN HANDLERS: ", command)

except MultiselectCommunicatorError as error:
print("ERROR IN NEGOTIATE WRITE")
raise MultiselectError() from error

raise MultiselectError("Negotiation failed: no matching protocol")
Copy link
Copy Markdown
Contributor Author

@lla-dane lla-dane Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debugged further and found an issue happening here:

These are the logs:
Image

as we see the broker wrote tls/1.0.0 and we wrote back na as we did not had the handler for tls, so now after this, the loop should have continued, and the broker should try for another security option, but rather we got a read error.

@seetadev @acul71

Copy link
Copy Markdown
Contributor Author

@lla-dane lla-dane Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this does not happen, when I dialed back to our python node, from a go-libp2p node.

image

Here the negotiation continued after this log

NEGOTIATE LOOP
COMMAND:  /tls/1.0.0
PROTOCOL NOT IN HANDLERS:  /tls/1.0.0

but the same thing does happen when the auto-tls broker dials in. I dont understand why this happens.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lla-dane I can't see the full images, can you please include the logs. with full commands, and output in text .
And a clear explanation to how to do the test and what you expect.
I'm confused sometimes I see echo and ping why ?
thanks

@lla-dane
Copy link
Copy Markdown
Contributor Author

lla-dane commented Dec 19, 2025

Yeah sure @acul71, I will explain everything properly.

So in the autotls procedure, the autotls-broker has to dial in our node (which has to bee publicly accesible) and run identify protocol on our node, too see that our node is real or not.

So presently when the autotls-broker is dialing in our node, there is some issue happening in the multiselect-stream protocol negotiation.

LOGS:

(.venv) ubuntu@ip-172-31-35-71:~/py-libp2p$ autotls-demo 
Listener ready, listening on:

/ip4/172.31.35.71/tcp/52577/p2p/12D3KooWQHYxsdkXCNZXw3qtuby71PKvj8AqfjBp6y7kVoq9CfmP
/ip4/127.0.0.1/tcp/52577/p2p/12D3KooWQHYxsdkXCNZXw3qtuby71PKvj8AqfjBp6y7kVoq9CfmP

Run this from the same folder in another console:

autotls-demo -d /ip4/172.31.35.71/tcp/52577/p2p/12D3KooWQHYxsdkXCNZXw3qtuby71PKvj8AqfjBp6y7kVoq9CfmP -psk 0 -t tcp

Waiting for incoming connection...

Base36 PeerID: k51qzi5uqu5dljhbovkdodhhbbgiz9q6kd92rw0u24gs8ti28ncbl58mtw5gri

GENERATING RSA-KEY (2048)...
STARTING ACME ACCOUTN CREATION SEQUENCE...

ACCOUNT-URL: https://acme-staging-v02.api.letsencrypt.org/acme/acct/251768963
ORDER-URL:  https://acme-staging-v02.api.letsencrypt.org/acme/order/251768963/29664454213
AUTH-URL:  https://acme-staging-v02.api.letsencrypt.org/acme/authz/251768963/20774698483
FINALIZE-URL:  https://acme-staging-v02.api.letsencrypt.org/acme/finalize/251768963/29664454213

GETTING THE DNS-01 CHALLENGE FROM ACME...

CHALL-URL:  https://acme-staging-v02.api.letsencrypt.org/acme/chall/251768963/20774698483/KF7sIg
DNS-TOKEN:  zKPMtnHad1scCwuE1qOUdrEFESt0jDL0Y8VeEUhuVC8
JWK-THUMBPRINT:  0u519mEYgNxqfvo8pPIaT1blGieK3Yw-8HKLCQm8zRQ
KEY-AUTH:  sUFBRPBv-L4GWkM9lyzkcEB14ucoCOhvFQKQqHoqYxA

INITIATION PEER-ID AUTHENTICATION WITH AUTO-TLS BROKER...

 {'User-Agent': 'py-libp2p/example/autotls', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'Authorization': 'libp2p-PeerID opaque="_g6BGCUng3W7suhCmo7HxtP7BTUhPcAvftVUlmh4ZFV7ImNsaWVudC1wdWJsaWMta2V5IjoiQ0FFU0lOYjZnVkNsVFExa2dISjdzK1crWDRmaDhLdUVNWUJyTEU0VEdTV2ZBdmdlIiwiY2hhbGxlbmdlLWNsaWVudCI6IjNMS2hIbmtHVmtVV2d1eEtwOTBNWURaUkdMVHl1akI2al9nWXRxX1V2V0k9IiwiaG9zdG5hbWUiOiJyZWdpc3RyYXRpb24ubGlicDJwLmRpcmVjdCIsImNyZWF0ZWQtdGltZSI6IjIwMjUtMTItMTlUMDM6MTU6MDUuMzIxMzU3ODExWiJ9", sig="UhgmGw1e_qjnl4o4vBRfwlpUmEO6Ttdg4oedG5CAYkV9Unnp7QsHAQB5I_PuCDgSSMyQvm63Lzf-1VGGcP3JCQ=="', 'Content-Length': '160'}

 {"value": "sUFBRPBv-L4GWkM9lyzkcEB14ucoCOhvFQKQqHoqYxA", "addresses": ["/ip4/13.126.88.127/tcp/52577/p2p/12D3KooWQHYxsdkXCNZXw3qtuby71PKvj8AqfjBp6y7kVoq9CfmP"]}

SERVER_PEER_ID:  12D3KooWAtWdWqQkWFqkWMix92dTmG6mEC781isvRfarhneMSqUy
BEARER TOKEN:  UJ_k2P9S1oqOMAAdNzsfIn-21L0f_pUQ1gVPvTir6397ImlzLXRva2VuIjp0cnVlLCJwZWVyLWlkIjoiMTJEM0tvb1dRSFl4c2RrWENOWlh3M3F0dWJ5NzFQS3ZqOEFxZmpCcDZ5N2tWb3E5Q2ZtUCIsImhvc3RuYW1lIjoicmVnaXN0cmF0aW9uLmxpYnAycC5kaXJlY3QiLCJjcmVhdGVkLXRpbWUiOiIyMDI1LTEyLTE5VDAzOjE1OjA1Ljg1NTg2NzEzMloifQ==

GOT A STREAM
GET-REMOTE-ADDR:  <trio.socket.socket fd=10, family=2, type=1, proto=6, laddr=('172.31.35.71', 52577)>
REOMTE_ADDR:  None
GOT THE STREAM HERE NOW
GOING FOR UPGRADING THE INBOUND RAW CONN...
GOING TO UPGRADE INBOUND CONN
SECURE-INBOUND

NOT THE INITIATOR, SO NEGOTIATING..

NEGOTIATE LOOP
COMMAND:  /tls/1.0.0
PROTOCOL NOT IN HANDLERS:  /tls/1.0.0

NEGOTIATE LOOP
ERROR IN NEGOTIATE READ

INBOUND CONNECTION CAME AND THREW ERROR
failed to upgrade security for peer at /ip4/172.31.35.71/tcp/52577

These are the first logs. There are basically to run the autotls-demo script. Here we got dialed in here in this part GOT A STREAM by the broker, after this it seems from the logs that, the broker wrote tls/1.0.0 for security upgrade, and then out node wrote back with na in this log: PROTOCOL NOT IN HANDLERS: /tls/1.0.0, as it did not have tls transport, and after that somehow the connection gets dropped, which shouldn't happen.

@lla-dane
Copy link
Copy Markdown
Contributor Author

lla-dane commented Dec 19, 2025

Since the p2p-forge autotls-broker repo: https://github.com/ipshipyard/p2p-forge, uses go-libp2p, I dialed in our node from a go-libp2p node to see what happens during the multistream-select protocol neogtiation.

DIALER:

shelby@soiarch ~/Desktop/libp2p/go/go-libp2p/examples/echo ❯ ./echo -l 9000 -d /ip4/192.168.31.130/tcp/37465/p2p/12D3KooWQKT71wATmA8guPXkKvnMMcFDDCz5XPWeKT1JnwKSTbw5
2025/12/19 08:52:29 I am /ip4/127.0.0.1/tcp/9000/p2p/QmZuswjvmoVeVHr8URgoXgkMp5kWgQuj1WqN8cbJtydg9P
2025/12/19 08:52:29 sender opening stream
2025/12/19 08:52:29 failed to negotiate protocol: protocols not supported: [/echo/1.0.0]

LISTENER:

(.venv) shelby@soiarch ~/Desktop/libp2p/py-libp2p ❯ ping-demo 
Listener ready, listening on:

/ip4/192.168.31.130/tcp/37465/p2p/12D3KooWQKT71wATmA8guPXkKvnMMcFDDCz5XPWeKT1JnwKSTbw5
/ip4/192.168.122.1/tcp/37465/p2p/12D3KooWQKT71wATmA8guPXkKvnMMcFDDCz5XPWeKT1JnwKSTbw5
/ip4/127.0.0.1/tcp/37465/p2p/12D3KooWQKT71wATmA8guPXkKvnMMcFDDCz5XPWeKT1JnwKSTbw5

Run this from the same folder in another console:

ping-demo -d /ip4/192.168.31.130/tcp/37465/p2p/12D3KooWQKT71wATmA8guPXkKvnMMcFDDCz5XPWeKT1JnwKSTbw5 -psk 0 -t tcp

Waiting for incoming connection...

GOT A STREAM
GET-REMOTE-ADDR:  <trio.socket.socket fd=11, family=2, type=1, proto=6, laddr=('192.168.31.130', 37465), raddr=('192.168.31.130', 38950)>
REOMTE_ADDR:  ('192.168.31.130', 38950)
GOT THE STREAM HERE NOW
GOING FOR UPGRADING THE INBOUND RAW CONN...
GOING TO UPGRADE INBOUND CONN
SECURE-INBOUND

NOT THE INITIATOR, SO NEGOTIATING..

NEGOTIATE LOOP
COMMAND:  /tls/1.0.0
PROTOCOL NOT IN HANDLERS:  /tls/1.0.0

NEGOTIATE LOOP
COMMAND:  /noise
PROTOCOL:  /noise
TRANSPORT SELECTED
GET-REMOTE-ADDR:  <trio.socket.socket fd=11, family=2, type=1, proto=6, laddr=('192.168.31.130', 37465), raddr=('192.168.31.130', 38950)>
GET-REMOTE-ADDR:  <trio.socket.socket fd=11, family=2, type=1, proto=6, laddr=('192.168.31.130', 37465), raddr=('192.168.31.130', 38950)>

NEGOTIATE LOOP
COMMAND:  /ipfs/id/1.0.0
GET-REMOTE-ADDR:  <trio.socket.socket fd=11, family=2, type=1, proto=6, laddr=('192.168.31.130', 37465), raddr=('192.168.31.130', 38950)>

SOME ADDR:  /ip4/192.168.31.130/tcp/38950

NEGOTIATE LOOP
COMMAND:  /echo/1.0.0
PROTOCOL NOT IN HANDLERS:  /echo/1.0.0

NEGOTIATE LOOP
Error in handle_incoming for peer QmZuswjvmoVeVHr8URgoXgkMp5kWgQuj1WqN8cbJtydg9P: IncompleteReadError: {'requested_count': 2, 'received_count': 0}
ERROR IN NEGOTIATE READ

for just debugging purpose, I dialed to our py-libp2p node from the echo example of go-libp2p. I just needed to see how the multistream-select protocol negotiation goes.
Now as we see from the LISTENER logs, the go-libp2p node wrote "tls/1.0.0", and our node again wrote back with na, but here go-libp2p node comes again with noise.
Now this particular thing did not happened when the autotls-broker dialed in. So this is mainly the issue. We have to make sure that the broker node's dial in goes successfully.

@lla-dane
Copy link
Copy Markdown
Contributor Author

@acul71: For testing, I have DM'd you the ec2 instance keys and how to connect to the instance on discord. There you can simply run the autotls-demo command in the py-libp2p repo, and everything will run.

@acul71
Copy link
Copy Markdown
Contributor

acul71 commented Jan 7, 2026

Hello @lla-dane @seetadev
First thing I've tried is to exclude that there is a transport-select negotiation evident issue
It doesn't, see below
Now I'm digging into this issue further, will come back

############

Suspecting it could be a transport-select negotiation issue, I've verified that py-libp2p's transport-select negotiation works correctly with the main branch. The broker negotiation issue seems not a py-libp2p transport-select bug, but rather a broker-specific issue in the HTTP handler context.

Evidence: Broker Simulation Test

I created a Go dialer that exactly replicates the broker's libp2p.New configuration and connection behavior. This dialer successfully connects to py-libp2p (main branch) and correctly negotiates Noise fallback.

Test Results

Go dialer (broker simulation) → py-libp2p (main branch)
Result: ✅ SUCCESS - Connected via /noise in 20-26ms

Broker Simulation Code

The following Go program exactly mimics the broker's connection behavior:

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/libp2p/go-libp2p"
	"github.com/libp2p/go-libp2p/core/peer"
	"github.com/libp2p/go-libp2p/p2p/net/swarm"
	ma "github.com/multiformats/go-multiaddr"
)

// This mimics what the broker does: creates a libp2p host with default security
// (TLS + Noise) and tries to connect to another peer.
func main() {
	if len(os.Args) < 2 {
		log.Fatal("Usage: dialer <multiaddr>")
	}
	targetAddr := os.Args[1]

	fmt.Printf("Creating libp2p host with default security (TLS + Noise)...\n")

	// Create host EXACTLY like the broker does
	h, err := libp2p.New(
		libp2p.NoListenAddrs,
		libp2p.DisableRelay(),
		libp2p.WithDialTimeout(10*time.Second),
		libp2p.SwarmOpts(swarm.WithDialTimeoutLocal(10*time.Second)),
	)
	if err != nil {
		log.Fatalf("Failed to create host: %v", err)
	}
	defer h.Close()

	fmt.Printf("Host created with ID: %s\n", h.ID())
	fmt.Printf("Dialing target: %s\n", targetAddr)

	// Parse the multiaddr
	maddr, err := ma.NewMultiaddr(targetAddr)
	if err != nil {
		log.Fatalf("Invalid multiaddr: %v", err)
	}

	// Extract peer info
	info, err := peer.AddrInfoFromP2pAddr(maddr)
	if err != nil {
		log.Fatalf("Failed to extract peer info: %v", err)
	}

	fmt.Printf("Target peer ID: %s\n", info.ID)
	fmt.Printf("Target addresses: %v\n", info.Addrs)

	// Try to connect with timeout
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	startTime := time.Now()
	fmt.Printf("Connecting...\n")

	err = h.Connect(ctx, *info)
	duration := time.Since(startTime)

	if err != nil {
		fmt.Printf("FAILED to connect after %v: %v\n", duration, err)
	} else {
		fmt.Printf("SUCCESS! Connected in %v\n", duration)
		
		// Check what security protocol was used
		conns := h.Network().ConnsToPeer(info.ID)
		for _, conn := range conns {
			fmt.Printf("Connection security: %s\n", conn.ConnState().Security)
		}
	}
}

Python Listener Code

The Python listener used for testing (main branch, no fixes):

#!/usr/bin/env python3
"""
Simple Python libp2p listener for incremental fix testing.
This will be used to test each fix incrementally with the Go dialer.
"""
import logging
import sys
import trio
import multiaddr
import socket

# Enable basic logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)

from libp2p import new_host
from libp2p.crypto.secp256k1 import create_new_key_pair
from libp2p.custom_types import TProtocol

def find_free_port():
    """Find a free port for listening."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(('', 0))
        s.listen(1)
        port = s.getsockname()[1]
    return port

PROTOCOL_ID = TProtocol("/echo/1.0.0")

async def echo_handler(stream):
    """Echo handler - reads and echoes back data."""
    print(f"[ECHO] Got stream! Protocol: {stream.get_protocol()}", flush=True)
    try:
        data = await stream.read(1024)
        print(f"[ECHO] Received: {data!r}", flush=True)
        await stream.write(data)
        print(f"[ECHO] Echoed back", flush=True)
    except Exception as e:
        print(f"[ECHO] Error: {e}", flush=True)
    finally:
        await stream.close()

async def main():
    port = find_free_port()
    print(f"[MAIN] Creating libp2p host on port {port}...", flush=True)
    
    # Create a deterministic key for testing
    key_pair = create_new_key_pair()
    
    host = new_host(key_pair=key_pair)
    listen_addr = [multiaddr.Multiaddr(f"/ip4/127.0.0.1/tcp/{port}")]
    
    async with host.run(listen_addrs=listen_addr):
        # Set up echo handler
        host.set_stream_handler(PROTOCOL_ID, echo_handler)
        
        peer_id = host.get_id().to_string()
        full_addr = f"/ip4/127.0.0.1/tcp/{port}/p2p/{peer_id}"
        
        print(f"\n{'='*60}", flush=True)
        print(f"[MAIN] Python libp2p host started!", flush=True)
        print(f"[MAIN] Peer ID: {peer_id}", flush=True)
        print(f"[MAIN] Full address: {full_addr}", flush=True)
        print(f"\n[MAIN] To test with Go dialer, run:", flush=True)
        print(f"  /tmp/test_broker_go/dialer_test \"{full_addr}\"", flush=True)
        print(f"{'='*60}\n", flush=True)
        
        print("[MAIN] Waiting for connections (Ctrl+C to stop)...", flush=True)
        
        # Wait forever
        try:
            await trio.sleep_forever()
        except KeyboardInterrupt:
            print("\n[MAIN] Shutting down...", flush=True)

if __name__ == "__main__":
    trio.run(main)

Test Execution

Python listener (main branch, no fixes):

cd /tmp/py-libp2p && source venv/bin/activate
python3 test_incremental_fixes.py
# Output: Listening on /ip4/127.0.0.1/tcp/54027/p2p/16Uiu2HAm...

Go dialer (broker simulation):

/tmp/test_broker_go/dialer_test "/ip4/127.0.0.1/tcp/54027/p2p/16Uiu2HAm..."

Result:

SUCCESS! Connected in 20.516862ms
Connection security: /noise

What This Proves

  1. py-libp2p's transport-select works correctly - The main branch successfully handles TLS → Noise fallback
  2. Broker's HTTP handler context is the issue - The actual broker fails even on localhost

Comparison: Broker Replica vs Actual Broker

Test Configuration Network Result
Broker replica Same libp2p.New config localhost ✅ SUCCESS (10ms)
Actual broker Same libp2p.New config localhost ❌ FAILS (10s timeout)

Key difference: Broker replica runs as standalone program, actual broker runs inside HTTP handler context.

@acul71
Copy link
Copy Markdown
Contributor

acul71 commented Jan 10, 2026

@seetadev @seetadev

Final Answer: What Was Actually Needed?

✅ CONCLUSION: Only autotls.py Fix Was Required!

The test with only autotls.py changes (all core modules reverted to original) worked perfectly:

Test Results:

  1. No 15-second delay - Broker connected immediately
  2. Broker fallback worked - TLS → "na" → Noise
  3. Noise handshake completed - All 3 messages exchanged
  4. Identify protocol worked - Successfully handled request
  5. HTTP response: 200 OK - Broker authentication successful

Evidence from Logs:

COMMAND:  /tls/1.0.0
PROTOCOL NOT IN HANDLERS:  /tls/1.0.0
COMMAND:  /noise  ← Broker fell back!
PROTOCOL:  /noise  ← Noise selected!
Status code: 200  ← Success!

What This Means:

✅ REQUIRED:

  • autotls.py: Convert blocking requests calls to async using trio.to_thread.run_sync()
    • This was the ONLY fix needed
    • The blocking requests were blocking the entire event loop

❌ NOT REQUIRED:

  • multiselect.py: The original code already had the continue statement (or it wasn't needed)
  • multiselect.py: The original handshake mode worked fine
  • trio.py: TCP_NODELAY was not needed (or already present)
  • All debugging statements: Not needed for functionality

Core Modules Were Already Fine!

The py-libp2p core modules were already correct and didn't need any changes. The only issue was in the application code (autotls.py) using blocking I/O.

Recommendation:

  1. Keep: Only the autotls.py async fixes
  2. Revert: All core module changes (they're not needed)
  3. Remove: All debugging statements (they were just for investigation)

Summary:

  • Root Cause: Blocking requests calls in autotls.py blocking the Trio event loop
  • Fix: Convert to async using trio.to_thread.run_sync()
  • Result: Works perfectly with original core modules

The core py-libp2p implementation was already correct! 🎉

@acul71
Copy link
Copy Markdown
Contributor

acul71 commented Jan 10, 2026

Auto-TLS Protocol Compliance Analysis

Auto-TLS Protocol Steps (from spec)

According to specs/tls/autotls-client.md, the complete Auto-TLS flow requires:

  1. Node requests a challenge from the ACME server
  2. Node sends the challenge to the broker
  3. Broker tests node and sets DNS record (fulfilling challenge)
  4. Node queries DNS until it sees that the broker has fulfilled the challenge
  5. Node signals to ACME server that challenge is fulfilled
  6. ACME server checks challenge in broker
  7. Node sends CSR to finalize certificate request
  8. Node polls ACME server until certificate is ready for download
  9. Node downloads certificate

Analysis of Your Output

✅ Step 1: Request Challenge from ACME Server

Evidence from output:

GENERATING RSA-KEY (2048)...
STARTING ACME ACCOUTN CREATION SEQUENCE...
ACCOUNT-URL: https://acme-staging-v02.api.letsencrypt.org/acme/acct/256939003
ORDER-URL:  https://acme-staging-v02.api.letsencrypt.org/acme/order/256939003/30421088383
AUTH-URL:  https://acme-staging-v02.api.letsencrypt.org/acme/authz/256939003/21118525443
FINALIZE-URL:  https://acme-staging-v02.api.letsencrypt.org/acme/finalize/256939003/30421088383

GETTING THE DNS-01 CHALLENGE FROM ACME...
CHALL-URL:  https://acme-staging-v02.api.letsencrypt.org/acme/chall/256939003/21118525443/vB58Dw
DNS-TOKEN:  kr6I-CgAg73K-bAISKbiB30BvkUd_NvSQIX4RTabsFI
JWK-THUMBPRINT:  40JLMPkP3cRKtd6hnDYZ2dw6j0kW0_kHbLxtmb5kBRM
KEY-AUTH:  A3Q81nkM_KmRYGGCIkViBvrRjqe8s7GGiKeB2J-TL54

Compliance:COMPLETE

  • ACME account created
  • Order created for *.{b36peerid}.libp2p.direct
  • DNS-01 challenge retrieved
  • keyAuthorization generated

✅ Step 2: Send Challenge to Broker

Evidence from output:

INITIATION PEER-ID AUTHENTICATION WITH AUTO-TLS BROKER...

[HTTP REQUEST]
Request body: {"value": "A3Q81nkM_KmRYGGCIkViBvrRjqe8s7GGiKeB2J-TL54", "addresses": ["/ip4/87.106.117.254/tcp/4444/p2p/12D3KooWRZeTBDW2EUDRnGgD5ZDCN6A8wQSiBymVaEV6XZQ33ULK"]}

[HTTP RESPONSE]
Status code: 200

Compliance:COMPLETE

  • Peer ID authentication completed
  • keyAuthorization sent to broker
  • Public addresses sent to broker
  • Bearer token received: CXyTFajqH00k2GMNDrYRSM9_FFIGpjag5JV-79FxueF7...

✅ Step 3: Broker Tests Node and Sets DNS Record

Evidence from output:

GOT A STREAM
REOMTE_ADDR:  ('18.188.47.119', 53144)  ← Broker connected!
COMMAND:  /tls/1.0.0
PROTOCOL NOT IN HANDLERS:  /tls/1.0.0
COMMAND:  /noise
PROTOCOL:  /noise
TRANSPORT SELECTED
... Noise handshake completed ...
[IDENTIFY] Incoming identify request from peer: 12D3KooWEGNvP76A8P6ueL8xhkx3nVji5D5tVY7usEFGrb6mg2yQ
[IDENTIFY] Successfully handled identify request
Status code: 200  ← Broker verification successful!

Compliance:COMPLETE

  • Broker dialed back to node (connection established)
  • Security negotiation completed (TLS → "na" → Noise fallback)
  • Identify protocol completed (broker verified node's identity)
  • HTTP response: 200 OK (broker confirmed dial-back was successful)

Note: The broker's testAddresses function (from p2p-forge) dials back to verify the node is reachable. The successful connection and identify exchange proves the node is reachable, so the broker should have set the DNS record.

❌ Step 4: Node Queries DNS

Missing from output:

  • No DNS queries shown
  • No polling for TXT _acme-challenge.{b36peerid}.libp2p.direct
  • No polling for A {dashed-ip}.{b36peerid}.libp2p.direct

Compliance:NOT IMPLEMENTED

❌ Step 5: Signal Challenge Completion to ACME

Missing from output:

  • No POST to chalUrl with empty body
  • No extraction of checkUrl

Compliance:NOT IMPLEMENTED

❌ Step 6: Poll ACME Server for Challenge Status

Missing from output:

  • No polling of checkUrl
  • No verification of status: valid

Compliance:NOT IMPLEMENTED

❌ Step 7: Finalize Certificate Request (CSR)

Missing from output:

  • No CSR generation
  • No POST to finalizeUrl with CSR

Compliance:NOT IMPLEMENTED

❌ Step 8: Poll ACME Server for Certificate

Missing from output:

  • No polling of orderUrl
  • No checking for status != "processing"

Compliance:NOT IMPLEMENTED

❌ Step 9: Download Certificate

Missing from output:

  • No GET request to certDownloadUrl
  • No certificate downloaded

Compliance:NOT IMPLEMENTED

Summary

✅ Completed Steps (3/9):

  1. ✅ Request challenge from ACME server
  2. ✅ Send challenge to broker
  3. ✅ Broker tests node (dial-back verification successful)

❌ Missing Steps (6/9):

  1. ❌ Query DNS until challenge is fulfilled
  2. ❌ Signal challenge completion to ACME
  3. ❌ Poll ACME for challenge status
  4. ❌ Finalize certificate request (CSR)
  5. ❌ Poll ACME for certificate readiness
  6. ❌ Download certificate

Conclusion

Partial Compliance: The output shows the first 3 steps of the Auto-TLS protocol were completed successfully:

  • ACME challenge request ✅
  • Broker authentication and challenge submission ✅
  • Broker dial-back verification ✅

However, the remaining 6 steps are not implemented in the current autotls.py example:

  • DNS polling
  • ACME challenge completion signaling
  • Certificate finalization and download

The code stops after receiving the HTTP 200 OK from the broker, which indicates the broker successfully verified the node's reachability. But the full Auto-TLS flow requires completing the ACME certificate issuance process.

Recommendation

To achieve full Auto-TLS protocol compliance, the following steps need to be implemented:

  1. DNS Polling (Step 4):

    • Query TXT _acme-challenge.{b36peerid}.libp2p.direct
    • Query A {dashed-ip}.{b36peerid}.libp2p.direct
    • Poll until DNS records are set (with exponential backoff, max dns_timeout)
  2. Challenge Completion (Step 5):

    • POST empty body {} to chalUrl with JWT signing
    • Extract checkUrl from response
  3. Poll Challenge Status (Step 6):

    • Poll checkUrl until status: valid or status: invalid
    • Use exponential backoff, max acme_timeout
  4. Finalize Certificate (Step 7):

    • Generate CSR for *.{b36peerid}.libp2p.direct
    • POST CSR to finalizeUrl
  5. Poll Certificate (Step 8):

    • Poll orderUrl until status != "processing"
    • Extract certificate URL
  6. Download Certificate (Step 9):

    • GET request to certDownloadUrl
    • Save certificate

The current implementation is a proof-of-concept that demonstrates the broker dial-back mechanism works, but it does not complete the full certificate issuance flow.

@lla-dane lla-dane force-pushed the autotls branch 4 times, most recently from 855360f to 8898d1b Compare January 11, 2026 20:34
lla-dane pushed a commit to lla-dane/py-libp2p that referenced this pull request Jan 11, 2026
- Enhanced get_remote_address() in TrioTCPStream with address caching
  and defensive checks to handle socket state transitions gracefully
- Fixed Ed25519PublicKey initialization to use from_bytes() method
- Added proper type annotation for server_id: ID | None
- Added None check for hostname before passing to ClientInitiatedHandshake
- Removed unused variables (commented with explanations for future use)
- Removed dead code (unused function calls with hardcoded port)
- Removed debug print statements in favor of proper logging
- Fixed code formatting, import ordering, and line length violations

This resolves the get_remote_address() exception that was occurring
when the Auto-TLS broker dials back into the node.

Fixes issue reported in PR libp2p#1072 comments.
lla-dane pushed a commit to lla-dane/py-libp2p that referenced this pull request Jan 12, 2026
- Enhanced get_remote_address() in TrioTCPStream with address caching
  and defensive checks to handle socket state transitions gracefully
- Fixed Ed25519PublicKey initialization to use from_bytes() method
- Added proper type annotation for server_id: ID | None
- Added None check for hostname before passing to ClientInitiatedHandshake
- Removed unused variables (commented with explanations for future use)
- Removed dead code (unused function calls with hardcoded port)
- Removed debug print statements in favor of proper logging
- Fixed code formatting, import ordering, and line length violations

This resolves the get_remote_address() exception that was occurring
when the Auto-TLS broker dials back into the node.

Fixes issue reported in PR libp2p#1072 comments.
@lla-dane
Copy link
Copy Markdown
Contributor Author

All the linting errors and merge conflicts are resolved. The working example of autotls is under examples/autotls. For autotls to work, there needs to be a publicly routable IP address. Until the development phase, I have hardcoded public-ip to this: 13.126.88.127(a linux server I am running).

Heres's the demo:

autotls.mp4

Next I will start with restructuring all the code, in a separate autotls module, and setup a clean and modular example from there.

CCing: @seetadev

lla-dane pushed a commit to lla-dane/py-libp2p that referenced this pull request Jan 19, 2026
- Enhanced get_remote_address() in TrioTCPStream with address caching
  and defensive checks to handle socket state transitions gracefully
- Fixed Ed25519PublicKey initialization to use from_bytes() method
- Added proper type annotation for server_id: ID | None
- Added None check for hostname before passing to ClientInitiatedHandshake
- Removed unused variables (commented with explanations for future use)
- Removed dead code (unused function calls with hardcoded port)
- Removed debug print statements in favor of proper logging
- Fixed code formatting, import ordering, and line length violations

This resolves the get_remote_address() exception that was occurring
when the Auto-TLS broker dials back into the node.

Fixes issue reported in PR libp2p#1072 comments.
@lla-dane lla-dane force-pushed the autotls branch 5 times, most recently from fb3002a to 16955b8 Compare January 20, 2026 05:31
lla-dane pushed a commit to lla-dane/py-libp2p that referenced this pull request Jan 20, 2026
- Enhanced get_remote_address() in TrioTCPStream with address caching
  and defensive checks to handle socket state transitions gracefully
- Fixed Ed25519PublicKey initialization to use from_bytes() method
- Added proper type annotation for server_id: ID | None
- Added None check for hostname before passing to ClientInitiatedHandshake
- Removed unused variables (commented with explanations for future use)
- Removed dead code (unused function calls with hardcoded port)
- Removed debug print statements in favor of proper logging
- Fixed code formatting, import ordering, and line length violations

This resolves the get_remote_address() exception that was occurring
when the Auto-TLS broker dials back into the node.

Fixes issue reported in PR libp2p#1072 comments.
@lla-dane
Copy link
Copy Markdown
Contributor Author

autotls-mpv.mp4

This is a latest demo on the autotls procedure. There are a few vulnerabilities(and things to do) in the TLS handshake procedure. Some of the hacks that I did were:

  • created a _do_primitive_key_exchange just before starting the TLS handshake in security/tls/io.py. Did this because the listner's ssl context cannot ask for inbound conn's TLS certificate, so instead of using a placeholder key(which would be wrong), now we get the public key from the exchange.

We can get the certificate, on the listener's side by giving its ssl context a trust store, but I need to see how to properly do it, which would be done in future PRs

  • We are skipping on doing verification checks on the received ACME certificates on the client's end, planning to do that in future PRs

  • Currently the public IP of a linux server(which I was using for testing) is hardcoded in the examples/autotls/autotls.py, which I will remove after everything is fixed and resolved after review.

There may be many more issues, that you find while reveiwing, I will resolve them as soon as they are flagged.

Docstrings are remaining, will work on them next

Comment on lines +29 to +35
def generate_rsa_key(bits: int = 2048) -> RSAPrivateKey:
key = rsa.generate_private_key(
public_exponent=65537,
key_size=bits,
backend=default_backend(),
)
return key
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curretly the procedure is using this RSA key generation technique, will switch to native RSA key generation in the future PRs.

Comment on lines +135 to +137
# TODO: need to also verify the signature sent by the server
# sig_b64 = msg["sig"] # Will be used for signature verification

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This signature verification is remaining, will complete in a coming PR.

lla-dane and others added 16 commits January 30, 2026 19:34
- Enhanced get_remote_address() in TrioTCPStream with address caching
  and defensive checks to handle socket state transitions gracefully
- Fixed Ed25519PublicKey initialization to use from_bytes() method
- Added proper type annotation for server_id: ID | None
- Added None check for hostname before passing to ClientInitiatedHandshake
- Removed unused variables (commented with explanations for future use)
- Removed dead code (unused function calls with hardcoded port)
- Removed debug print statements in favor of proper logging
- Fixed code formatting, import ordering, and line length violations

This resolves the get_remote_address() exception that was occurring
when the Auto-TLS broker dials back into the node.

Fixes issue reported in PR libp2p#1072 comments.
Add the auto-generated examples.autotls.rst file to the repository
so that ReadTheDocs can find it when building the documentation.
This file is generated by sphinx-apidoc and is referenced in the
examples.rst toctree.
@lla-dane
Copy link
Copy Markdown
Contributor Author

lla-dane commented Jan 30, 2026

Hey @pacrob, @seetadev, @acul71 : I have added the docstrings and a few cleanups.

In the http-utils that I have used for closing the exchanges with ACME server and libp2p-forge listener, they were recommended by AI-agent, so it may be the case that it could have been done in a simpler way. I had limited knowledge about how http api works while writing this, so relied on AI and was focused on making it work. If there are feedbacks regarding refactoring the http utils/patterns used in ACME and AutoTLS-broker negotiation code, I will surely follow them up in a future PR along with 2 changes that I have mentioned in the comments.

Other than that will fix any logic errors that will flagged in this PR. Below is a ss of the working ping exchange over TCP (will integrate it with web-transport and QUIC in a future PR) between 2 peers with AutoTLS certificates being used in TLS handshake.

Listener

(.venv) ubuntu@ip-172-31-35-71:~/tmp/py-libp2p$ autotls-demo 
[INFO] root: Generated new key-pair...
[INFO] libp2p.autotls.acme: Key-Auth fetched from ACME: 1bpTEc3UqQzwcOD8rQIoWvnqIu3YbRD9NhWIAFzwXEA
[INFO] libp2p.security.tls: [INC/OUT]: AUTO-TLS enabled, but ACME certificatenot cached yet, so reverting back to self-signed TLS
[INFO] libp2p.security.tls.io: Skipping the primitive key-exchange, either remote != py-libp2p node, or autotls not enabled
[WARNING] libp2p.security.tls: [INBOUND] Server couldn't fetch dialer's certificate
[WARNING] libp2p.security.tls: TLS inbound: no peer cert (Python ssl limitation)
[WARNING] libp2p.security.tls: TLS inbound: using peerid obtained from primitive key-exchange
[INFO] libp2p.autotls.broker: [DNS] challenge, completed by AUTO-TLS broker
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/chall/262181483/21438666303/kK_q-A "HTTP/1.1 200 OK"
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/chall/262181483/21438666303/kK_q-A "HTTP/1.1 200 OK"
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/chall/262181483/21438666303/kK_q-A "HTTP/1.1 200 OK"
[INFO] libp2p.autotls.acme: Notified ACME, about challenge completion
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/finalize/262181483/31217445103 "HTTP/1.1 200 OK"
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/order/262181483/31217445103 "HTTP/1.1 200 OK"
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/order/262181483/31217445103 "HTTP/1.1 200 OK"
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/cert/2c0d729640dba82b5ff297a967418fd2e244 "HTTP/1.1 200 OK"
[INFO] libp2p.autotls.acme: ACME-TLS certificate cached, DNS: ['*.k51qzi5uqu5dht63pmrylfzqqv2q52n304ta2pi03bwywhxoiosjo4u1tabyok.libp2p.direct'], b36_pid: k51qzi5uqu5dht63pmrylfzqqv2q52n304ta2pi03bwywhxoiosjo4u1tabyok
Listener ready, listening on:

/ip4/172.31.35.71/tcp/45599/p2p/12D3KooWEDDpiNwaQdaBuU86y47mKHDY9aTdyUGT1c1QWAMTbZMV
/ip4/127.0.0.1/tcp/45599/p2p/12D3KooWEDDpiNwaQdaBuU86y47mKHDY9aTdyUGT1c1QWAMTbZMV

Run this from the same folder in another console:

autotls-demo -d /ip4/172.31.35.71/tcp/45599/p2p/12D3KooWEDDpiNwaQdaBuU86y47mKHDY9aTdyUGT1c1QWAMTbZMV -new 1 -t tcp -tls 0

Waiting for incoming connection...
[INFO] libp2p.security.tls: [INC/OUT]: Loaded existing cert, DNS: ['*.k51qzi5uqu5dht63pmrylfzqqv2q52n304ta2pi03bwywhxoiosjo4u1tabyok.libp2p.direct']
[INFO] libp2p.security.tls.io: Primitive key exchange complete, remote_peer: 12D3KooWAwgQt8vYr6mNCBnGdAN5p3pYmfNwLVC7TkRyTsuMXBcR
[WARNING] libp2p.security.tls: [INBOUND] Server couldn't fetch dialer's certificate
[WARNING] libp2p.security.tls: TLS inbound: no peer cert (Python ssl limitation)
[WARNING] libp2p.security.tls: TLS inbound: using peerid obtained from primitive key-exchange
received ping from 12D3KooWAwgQt8vYr6mNCBnGdAN5p3pYmfNwLVC7TkRyTsuMXBcR
responded with pong to 12D3KooWAwgQt8vYr6mNCBnGdAN5p3pYmfNwLVC7TkRyTsuMXBcR

DIALER

(.venv) ubuntu@ip-172-31-35-71:~/tmp/py-libp2p$ autotls-demo -d /ip4/172.31.35.71/tcp/45599/p2p/12D3KooWEDDpiNwaQdaBuU86y47mKHDY9aTdyUGT1c1QWAMTbZMV -new 1 -t tcp -tls 0
[INFO] root: Generated new key-pair...
Listener ready, listening on:

/ip4/172.31.35.71/tcp/34357/p2p/12D3KooWAwgQt8vYr6mNCBnGdAN5p3pYmfNwLVC7TkRyTsuMXBcR
/ip4/127.0.0.1/tcp/34357/p2p/12D3KooWAwgQt8vYr6mNCBnGdAN5p3pYmfNwLVC7TkRyTsuMXBcR



[INFO] libp2p.autotls.acme: Key-Auth fetched from ACME: 7T2Tn9zRJ1PlGKlnnp8dTknnsPiOIqIPTYNZswKa8kI
[INFO] libp2p.security.tls: [INC/OUT]: AUTO-TLS enabled, but ACME certificatenot cached yet, so reverting back to self-signed TLS
[INFO] libp2p.security.tls.io: Skipping the primitive key-exchange, either remote != py-libp2p node, or autotls not enabled
[WARNING] libp2p.security.tls: [INBOUND] Server couldn't fetch dialer's certificate
[WARNING] libp2p.security.tls: TLS inbound: no peer cert (Python ssl limitation)
[WARNING] libp2p.security.tls: TLS inbound: using peerid obtained from primitive key-exchange
[INFO] libp2p.autotls.broker: [DNS] challenge, completed by AUTO-TLS broker
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/chall/262181543/21438673333/RWRlJQ "HTTP/1.1 200 OK"
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/chall/262181543/21438673333/RWRlJQ "HTTP/1.1 200 OK"
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/chall/262181543/21438673333/RWRlJQ "HTTP/1.1 200 OK"
[INFO] libp2p.autotls.acme: Notified ACME, about challenge completion
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/finalize/262181543/31217456853 "HTTP/1.1 200 OK"
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/order/262181543/31217456853 "HTTP/1.1 200 OK"
[INFO] httpx: HTTP Request: POST https://acme-staging-v02.api.letsencrypt.org/acme/cert/2c70c4163e3620220b5db78cddc7979fe293 "HTTP/1.1 200 OK"
[INFO] libp2p.autotls.acme: ACME-TLS certificate cached, DNS: ['*.k51qzi5uqu5dgllojykmuf69p2egif4i2jojomxvia4114redrenjrrxr5f78e.libp2p.direct'], b36_pid: k51qzi5uqu5dgllojykmuf69p2egif4i2jojomxvia4114redrenjrrxr5f78e
[INFO] libp2p.security.tls: [INC/OUT]: Loaded existing cert, DNS: ['*.k51qzi5uqu5dgllojykmuf69p2egif4i2jojomxvia4114redrenjrrxr5f78e.libp2p.direct']
[INFO] libp2p.security.tls.io: Primitive key exchange complete, remote_peer: 12D3KooWEDDpiNwaQdaBuU86y47mKHDY9aTdyUGT1c1QWAMTbZMV
[INFO] libp2p.security.tls: [OUTBOUND] Remote peer-cert: DNS: ['*.k51qzi5uqu5dht63pmrylfzqqv2q52n304ta2pi03bwywhxoiosjo4u1tabyok.libp2p.direct']
[WARNING] libp2p.security.tls: [TLS outbound]: certificate missing libp2p extension (likely autotls cert).
[WARNING] libp2p.security.tls: Skipping certificate-based peer verification. 
[WARNING] libp2p.security.tls: [TLS outbound]: using public key, from primitive exchange. 
sending ping to 12D3KooWEDDpiNwaQdaBuU86y47mKHDY9aTdyUGT1c1QWAMTbZMV
received pong from 12D3KooWEDDpiNwaQdaBuU86y47mKHDY9aTdyUGT1c1QWAMTbZMV

Comment thread libp2p/security/tls/io.py
Comment on lines +205 to +224
async def _do_primitive_key_exchange(self) -> None:
"""
Perform a primitive key exchange with the remote peer.

Sends the local public key over the raw connection, receives the
peer's key, and stores it along with the derived Peer ID for subsequent use.

:return: None
"""
pk_bytes = self.local_prim_pk

# Exchange
await self.raw_connection.write(pk_bytes)
data = await self.raw_connection.read(36)

pub_key_pb = PublicKey.deserialize_from_protobuf(data)
ed25518_key = Ed25519PublicKey.from_bytes(pub_key_pb.data)

self.remote_primitive_pk = ed25518_key
self.remote_pid = ID.from_pubkey(ed25518_key)
Copy link
Copy Markdown
Contributor Author

@lla-dane lla-dane Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this extra util, for making the local peers informed about the remote's public key in a TLS handshake. Previously a placeholder key-pair was generated, which was wrong. This will hinder in interop over AutoTLS, so will come up with a solution for this in future.

@pacrob @acul71

@seetadev
Copy link
Copy Markdown
Contributor

seetadev commented Feb 2, 2026

@lla-dane : Hey Abhinav — this is really solid work. 👏
Thanks for pushing this through and for the detailed context in the PR description and logs — that made the review much easier.

A few highlights from my side:

  • Great alignment with the libp2p Auto-TLS client spec. The way you’ve wired ACME account/order handling, DNS-01 via libp2p.direct, and PeerID-based domain binding closely follows the spec and the peer-id-auth flow. This feels like a correct and future-proof foundation.
  • Clear motivation and impact. The explanation around why CA-authorized certs matter (browser stacks, HTTPS proxies, Web PKI constraints) is spot on. This is a big step toward making py-libp2p interop-friendly in real-world TLS environments.
  • Implementation quality. The end-to-end flow — from challenge completion to certificate caching and reuse — looks correct, and the ping exchange logs over TCP with Auto-TLS certs are very reassuring. It’s great to see this working in practice, not just on paper.
  • Thoughtful iteration. Adding docstrings, cleaning things up, and calling out areas where the HTTP utils could be simplified later shows good engineering judgment. It’s totally reasonable to prioritize correctness and spec compliance first, and iterate on refactors in follow-up PRs.
  • Honest notes on limitations. The comments around Python TLS limitations and the primitive key-exchange fallback are appreciated. The added util to propagate the remote public key is clearly the right direction, and it’s good that this is explicitly documented as a stepping stone toward better AutoTLS interop.

Overall, this PR meaningfully moves py-libp2p forward. It unblocks Auto-TLS as a real, usable capability and sets us up nicely for QUIC, WebTransport, and broader browser-facing use cases in follow-ups.

From my side, this looks ready to merge once the remaining checks settle. 🙌

Huge thanks for the persistence and for tackling something this deep in the stack — really appreciate the work here.

@acul71 , @pacrob : Wish to also thank you for your great support and for enabling us get unblocked multiple times while working on the PR.

Luca, your help at the beginning was simply awesome :) Appreciate it.

@seetadev seetadev merged commit 4c854ce into libp2p:main Feb 2, 2026
37 of 38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AutoTLS Support for py-libp2p

3 participants