Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

libp2phttp: HTTP Peer ID Authentication #2854

Open
wants to merge 31 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2e7b82e
Initial implementation of http peer id auth
MarcoPolo Jun 29, 2024
54c6fa1
export client mutual auth
MarcoPolo Jun 29, 2024
30a0ac3
Add test vectors
MarcoPolo Jul 1, 2024
62d887a
Add test walkthrough for spec
MarcoPolo Jul 2, 2024
5041d97
Don't bother decoding challenges
MarcoPolo Jul 2, 2024
94ba8ae
Backport AppendEncode/AppendDecode
MarcoPolo Jul 2, 2024
ad2ee53
Rename origin to hostname
MarcoPolo Jul 2, 2024
3c3adf2
Read hostname from tls session
MarcoPolo Jul 5, 2024
5cb1f15
Add protocol ID constant
MarcoPolo Jul 9, 2024
3007fd8
Add marshalled zero key in test
MarcoPolo Jul 9, 2024
4d038a8
Rename PeerIDAuth to ServerPeerIDAuth
MarcoPolo Jul 9, 2024
3492299
PR comments
MarcoPolo Jul 19, 2024
e19d8b0
WIP
MarcoPolo Aug 23, 2024
a68e63d
Refactor handshake
MarcoPolo Aug 27, 2024
a61a437
Implement public API
MarcoPolo Aug 28, 2024
185382a
Nits
MarcoPolo Aug 28, 2024
e601971
Error if challenge is too short
MarcoPolo Aug 28, 2024
6ed98eb
nit
MarcoPolo Aug 28, 2024
273e892
Mod tidy
MarcoPolo Aug 28, 2024
212e572
nit
MarcoPolo Aug 28, 2024
4993c97
Use a newRequest function rather than shallow clone
MarcoPolo Aug 28, 2024
fbcede2
Add tests to generate examples for specs
MarcoPolo Aug 28, 2024
7a5faf8
Rename InsecureNoTLS. Update comment
MarcoPolo Sep 4, 2024
96f02ca
Add support for client-initiated handshake
MarcoPolo Sep 6, 2024
80a8569
Change handshake api a bit
MarcoPolo Sep 6, 2024
83e5a1f
Add Client Initiated handshake to API
MarcoPolo Sep 6, 2024
5e07918
Use ValidHostnameFn even with TLS set
MarcoPolo Sep 6, 2024
e0e261f
Couple of improvments in internal handshake package
MarcoPolo Sep 9, 2024
fbc0ac8
Clear GetBody as well; simply running handshake
MarcoPolo Sep 9, 2024
35d6843
Handle case where server refuses client-initiated handshake
MarcoPolo Sep 10, 2024
dbd59dc
Fix reference in test
MarcoPolo Sep 10, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions p2p/http/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package httppeeridauth

import (
logging "github.com/ipfs/go-log/v2"
"github.com/libp2p/go-libp2p/p2p/http/auth/internal/handshake"
)

const PeerIDAuthScheme = handshake.PeerIDAuthScheme

var log = logging.Logger("httppeeridauth")
MarcoPolo marked this conversation as resolved.
Show resolved Hide resolved
213 changes: 213 additions & 0 deletions p2p/http/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package httppeeridauth

import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"hash"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

logging "github.com/ipfs/go-log/v2"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/stretchr/testify/require"
)

// TestMutualAuth tests that we can do a mutually authenticated round trip
func TestMutualAuth(t *testing.T) {
logging.SetLogLevel("httppeeridauth", "DEBUG")

zeroBytes := make([]byte, 64)
serverKey, _, err := crypto.GenerateEd25519Key(bytes.NewReader(zeroBytes))
require.NoError(t, err)

type clientTestCase struct {
name string
clientKeyGen func(t *testing.T) crypto.PrivKey
}

clientTestCases := []clientTestCase{
{
name: "ED25519",
clientKeyGen: func(t *testing.T) crypto.PrivKey {
t.Helper()
clientKey, _, err := crypto.GenerateEd25519Key(rand.Reader)
require.NoError(t, err)
return clientKey
},
},
{
name: "RSA",
clientKeyGen: func(t *testing.T) crypto.PrivKey {
t.Helper()
clientKey, _, err := crypto.GenerateRSAKeyPair(2048, rand.Reader)
require.NoError(t, err)
return clientKey
},
},
}

type serverTestCase struct {
name string
serverGen func(t *testing.T) (*httptest.Server, *ServerPeerIDAuth)
}

serverTestCases := []serverTestCase{
{
name: "no TLS",
serverGen: func(t *testing.T) (*httptest.Server, *ServerPeerIDAuth) {
t.Helper()
auth := ServerPeerIDAuth{
PrivKey: serverKey,
ValidHostnameFn: func(s string) bool {
return s == "example.com"
},
TokenTTL: time.Hour,
NoTLS: true,
}

ts := httptest.NewServer(&auth)
t.Cleanup(ts.Close)
return ts, &auth
},
},
{
name: "TLS",
serverGen: func(t *testing.T) (*httptest.Server, *ServerPeerIDAuth) {
t.Helper()
auth := ServerPeerIDAuth{
PrivKey: serverKey,
ValidHostnameFn: func(s string) bool {
return s == "example.com"
},
TokenTTL: time.Hour,
}

ts := httptest.NewTLSServer(&auth)
t.Cleanup(ts.Close)
return ts, &auth
},
},
}

for _, ctc := range clientTestCases {
for _, stc := range serverTestCases {
t.Run(ctc.name+"+"+stc.name, func(t *testing.T) {
ts, server := stc.serverGen(t)
client := ts.Client()
roundTripper := instrumentedRoundTripper{client.Transport, 0}
client.Transport = &roundTripper
requestsSent := func() int {
defer func() { roundTripper.timesRoundtripped = 0 }()
return roundTripper.timesRoundtripped
}

tlsClientConfig := roundTripper.TLSClientConfig()
if tlsClientConfig != nil {
// If we're using TLS, we need to set the SNI so that the
// server can verify the request Host matches it.
tlsClientConfig.ServerName = "example.com"
}
clientKey := ctc.clientKeyGen(t)
clientAuth := ClientPeerIDAuth{PrivKey: clientKey}

expectedServerID, err := peer.IDFromPrivateKey(serverKey)
require.NoError(t, err)

req, err := http.NewRequest("POST", ts.URL, nil)
require.NoError(t, err)
req.Host = "example.com"
serverID, resp, err := clientAuth.AuthenticatedDo(client, req)
require.NoError(t, err)
require.Equal(t, expectedServerID, serverID)
require.NotZero(t, clientAuth.tokenMap["example.com"])
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 2, requestsSent())

// Once more with the auth token
req, err = http.NewRequest("POST", ts.URL, nil)
require.NoError(t, err)
req.Host = "example.com"
serverID, resp, err = clientAuth.AuthenticatedDo(client, req)
require.NotEmpty(t, req.Header.Get("Authorization"))
require.NoError(t, err)
require.Equal(t, expectedServerID, serverID)
require.NotZero(t, clientAuth.tokenMap["example.com"])
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 1, requestsSent(), "should only call newRequest once since we have a token")

t.Run("Tokens Expired", func(t *testing.T) {
// Clear the auth token on the server side
server.TokenTTL = 1 // Small TTL
time.Sleep(100 * time.Millisecond)
resetServerTokenTTL := sync.OnceFunc(func() {
server.TokenTTL = time.Hour
})

req, err := http.NewRequest("POST", ts.URL, nil)
require.NoError(t, err)
req.Host = "example.com"
req.GetBody = func() (io.ReadCloser, error) {
resetServerTokenTTL()
return nil, nil
}
serverID, resp, err = clientAuth.AuthenticatedDo(client, req)
require.NoError(t, err)
require.NotEmpty(t, req.Header.Get("Authorization"))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, expectedServerID, serverID)
require.NotZero(t, clientAuth.tokenMap["example.com"])
require.Equal(t, 3, requestsSent(), "should call newRequest 3x since our token expired")
})

t.Run("Tokens Invalidated", func(t *testing.T) {
// Clear the auth token on the server side
server.Hmac = func() hash.Hash {
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
panic(err)
}
return hmac.New(sha256.New, key)
}()

req, err := http.NewRequest("POST", ts.URL, nil)
req.GetBody = func() (io.ReadCloser, error) {
return nil, nil
}
require.NoError(t, err)
req.Host = "example.com"
serverID, resp, err = clientAuth.AuthenticatedDo(client, req)
require.NoError(t, err)
require.NotEmpty(t, req.Header.Get("Authorization"))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, expectedServerID, serverID)
require.NotZero(t, clientAuth.tokenMap["example.com"])
require.Equal(t, 3, requestsSent(), "should call newRequest 3x since our token expired")
})

})
}
}
}

type instrumentedRoundTripper struct {
http.RoundTripper
timesRoundtripped int
}

func (irt *instrumentedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
irt.timesRoundtripped++
return irt.RoundTripper.RoundTrip(req)
}

func (irt *instrumentedRoundTripper) TLSClientConfig() *tls.Config {
return irt.RoundTripper.(*http.Transport).TLSClientConfig
}
165 changes: 165 additions & 0 deletions p2p/http/auth/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package httppeeridauth

import (
"errors"
"fmt"
"net/http"
"sync"
"time"

"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/libp2p/go-libp2p/p2p/http/auth/internal/handshake"
)

type ClientPeerIDAuth struct {
PrivKey crypto.PrivKey
TokenTTL time.Duration

tokenMapMu sync.Mutex
tokenMap map[string]tokenInfo
}

type tokenInfo struct {
token string
insertedAt time.Time
peerID peer.ID
}

// AuthenticatedDo is like http.Client.Do, but it does the libp2p peer ID auth
// handshake if needed.
//
// It is recommended to pass in an http.Request with `GetBody` set, so that this
// method can retry sending the request in case a previously used token has
// expired.
func (a *ClientPeerIDAuth) AuthenticatedDo(client *http.Client, req *http.Request) (peer.ID, *http.Response, error) {
hostname := req.Host
a.tokenMapMu.Lock()
if a.tokenMap == nil {
a.tokenMap = make(map[string]tokenInfo)
}
ti, hasToken := a.tokenMap[hostname]
if hasToken && a.TokenTTL != 0 && time.Since(ti.insertedAt) > a.TokenTTL {
hasToken = false
MarcoPolo marked this conversation as resolved.
Show resolved Hide resolved
delete(a.tokenMap, hostname)
}
a.tokenMapMu.Unlock()

clientIntiatesHandshake := !hasToken
handshake := handshake.PeerIDAuthHandshakeClient{
Hostname: hostname,
PrivKey: a.PrivKey,
}
if clientIntiatesHandshake {
handshake.SetInitiateChallenge()
}

if hasToken {
// Try to make the request with the token
req.Header.Set("Authorization", ti.token)
resp, err := client.Do(req)
if err != nil {
return "", nil, err
}
if resp.StatusCode != http.StatusUnauthorized {
// our token is still valid
return ti.peerID, resp, nil
}
if req.GetBody == nil {
// We can't retry this request even if we wanted to.
// Return the response and an error
return "", resp, errors.New("expired token. Couldn't run handshake because req.GetBody is nil")
}
resp.Body.Close()

// Token didn't work, we need to re-authenticate.
// Run the server-initiated handshake
req = req.Clone(req.Context())
req.Body, err = req.GetBody()
if err != nil {
return "", nil, err
}

handshake.ParseHeader(resp.Header)
}
originalBody := req.Body

handshake.Run()
handshake.SetHeader(req.Header)

// Don't send the body before we've authenticated the server
req.Body = nil
resp, err := client.Do(req)
if err != nil {
return "", nil, err
}
resp.Body.Close()

err = handshake.ParseHeader(resp.Header)
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to set the status code first?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

On the client? Or do you mean assert the status code is 401?

if err != nil {
return "", nil, fmt.Errorf("failed to parse auth header: %w", err)
}
err = handshake.Run()
if err != nil {
return "", nil, fmt.Errorf("failed to run handshake: %w", err)
}

serverWasAuthenticated := false
_, err = handshake.PeerID()
if err == nil {
serverWasAuthenticated = true
}

req = req.Clone(req.Context())
if serverWasAuthenticated {
req.Body = originalBody
} else {
// Don't send the body before we've authenticated the server
req.Body = nil
}
handshake.SetHeader(req.Header)
resp, err = client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("failed to do authenticated request: %w", err)
}

err = handshake.ParseHeader(resp.Header)
if err != nil {
resp.Body.Close()
return "", nil, fmt.Errorf("failed to parse auth info header: %w", err)
}
err = handshake.Run()
if err != nil {
resp.Body.Close()
return "", nil, fmt.Errorf("failed to run auth info handshake: %w", err)
}

serverPeerID, err := handshake.PeerID()
if err != nil {
resp.Body.Close()
return "", nil, fmt.Errorf("failed to get server's peer ID: %w", err)
}
a.tokenMapMu.Lock()
a.tokenMap[hostname] = tokenInfo{
token: handshake.BearerToken(),
insertedAt: time.Now(),
peerID: serverPeerID,
}
a.tokenMapMu.Unlock()

if serverWasAuthenticated {
return serverPeerID, resp, nil
}

// Server wasn't authenticated earlier.
// We need to make one final request with the body now that we authenticated
// the server.
req = req.Clone(req.Context())
req.Body = originalBody
handshake.SetHeader(req.Header)
resp, err = client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("failed to do authenticated request: %w", err)
}
return serverPeerID, resp, nil
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a comment before each of the steps? This runs both Server initiated and client initiated handshake making things a bit complicated, comments would aid readability here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I've changed this to be a for loop instead since it's all the same thing.

}
Loading
Loading