Skip to content

Commit

Permalink
feat: switch to http peerID auth
Browse files Browse the repository at this point in the history
  • Loading branch information
aschmahmann committed Sep 4, 2024
1 parent 999ba3c commit e4350da
Show file tree
Hide file tree
Showing 7 changed files with 63 additions and 244 deletions.
15 changes: 2 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,12 @@ To claim a domain name like `<peerID>.libp2p.direct` requires:
To set an ACME challenge send an HTTP request to the server (for libp2p.direct this is registration.libp2p.direct)
```shell
curl -X POST "https://registration.libp2p.direct/v1/<peerID>/_acme-challenge" \
-H "Authorization: Bearer <signature>.<public_key>"
-H "Authorization: libp2p-PeerID bearer=\"<base64-encoded-opaque-blob>\""
-H "Content-Type: application/json" \
-d '{
"value": "your_acme_challenge_token",
"addresses": "[your_multiaddrs, comma_separated]"
}'
```

Where the signature is a base64 encoding of the signature for a [libp2p signed envelope](https://github.com/libp2p/specs/blob/master/RFC/0002-signed-envelopes.md)
where:
- The domain separation string is "peer-forge-domain-challenge"
- The payload type is the ASCII string "/peer-forge-domain-challenge"
- The payload bytes are the contents of the body of the request

If the public key is not extractable from the peerID then after the signature add a `.` followed by the base64 encoded
public key in the libp2p public key format.

Note: Per the [peerID spec](https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#peer-ids) the peerIDs with
extractable public keys are those that are encoded as fewer than 42 bytes (i.e. Ed25519 and Secp256k1), which means the
others (i.e. RSA and ECDSA) require the public keys to be in the Authorization header.
Where the bearer token is derived via the [libp2p HTTP PeerID Auth Specification](https://github.com/libp2p/specs/pull/564).
199 changes: 40 additions & 159 deletions acme/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@ package acme
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"

clog "github.com/coredns/coredns/plugin/pkg/log"
Expand All @@ -19,12 +18,11 @@ import (
"github.com/gorilla/mux"

"github.com/ipfs/go-datastore"
pool "github.com/libp2p/go-buffer-pool"
"github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peer"
httppeeridauth "github.com/libp2p/go-libp2p/p2p/http/auth"
"github.com/multiformats/go-multiaddr"
"github.com/multiformats/go-varint"
)

var log = clog.NewWithPlugin(pluginName)
Expand Down Expand Up @@ -57,144 +55,62 @@ func (c *acmeWriter) OnStartup() error {
c.mux = mux.NewRouter()
c.nlSetup = true

c.mux.HandleFunc("/v1/{peerID}/_acme-challenge", func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("must pass an authorization header"))
return
}

vars := mux.Vars(r)
peerIDStr, peerIDFound := vars["peerID"]
if !peerIDFound {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("peerID not found in request URL"))
return
}
peerID, err := peer.Decode(peerIDStr)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("invalid peer ID"))
return
}

pk, err := peerID.ExtractPublicKey()
if err != nil && errors.Is(err, peer.ErrNoPublicKey) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(fmt.Sprintf("unable to extract public key from peer ID: %s", err.Error())))
return
}

body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(fmt.Sprintf("error reading body: %s", err)))
return
}

authComponents := strings.Split(authHeader, ".")
if pk == nil {
if len(authComponents) != 2 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("must pass both a signature and public key if the public key cannot be extracted from the peerID"))
return
}
base64EncodedPubKey := authComponents[1]
encodedPubKey, err := base64.StdEncoding.DecodeString(base64EncodedPubKey)
sk, _, err := crypto.GenerateEd25519Key(rand.Reader)
if err != nil {
return err
}
authPeer := &httppeeridauth.ServerPeerIDAuth{
PrivKey: sk,
TokenTTL: time.Hour,
Next: func(peerID peer.ID, w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("could not decode public key"))
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(fmt.Sprintf("error reading body: %s", err)))
return
}
pk, err = crypto.UnmarshalPublicKey(encodedPubKey)
if err != nil {

typedBody := &requestBody{}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
if err := decoder.Decode(typedBody); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("could not unmarshal public key"))
_, _ = w.Write([]byte(fmt.Sprintf("error decoding body: %s", err)))
return
}

calculatedID, err := peer.IDFromPublicKey(pk)
// Value must be a base64url encoding of a SHA256 digest per https://datatracker.ietf.org/doc/html/rfc8555/#section-8.4
decodedValue, err := base64.RawURLEncoding.DecodeString(typedBody.Value)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("could not calculate peerID from public key"))
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(fmt.Sprintf("error decoding value as base64url: %s", err)))
return
}

if calculatedID != peerID {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("calculated peer ID does not match the passed peerID"))
if len(decodedValue) != 32 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("value is not a base64url of a SHA256 digest"))
return
}
} else {
if len(authComponents) != 1 {

if err := testAddresses(r.Context(), peerID, typedBody.Addresses); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("when the peerID can be extracted from the public key only the signature should be in the authorization header"))
_, _ = w.Write([]byte(fmt.Sprintf("error testing addresses: %s", err)))
return
}
}

base64EncodedSignature := authComponents[0]
signature, err := base64.StdEncoding.DecodeString(base64EncodedSignature)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(fmt.Sprintf("error decoding signature: %s", err)))
}

unsigned := makeUnsigned(signatureDomainString, signaturePayloadType, body)
defer pool.Put(unsigned)

verified, err := pk.Verify(unsigned, signature)
if !verified {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("invalid signature"))
return
}

if err != nil {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(fmt.Sprintf("error verifying signature: %s", err)))
return
}

typedBody := &requestBody{}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
if err := decoder.Decode(typedBody); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(fmt.Sprintf("error decoding body: %s", err)))
return
}

// Value must be a base64url encoding of a SHA256 digest per https://datatracker.ietf.org/doc/html/rfc8555/#section-8.4
decodedValue, err := base64.RawURLEncoding.DecodeString(typedBody.Value)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(fmt.Sprintf("error decoding value as base64url: %s", err)))
return
}

if len(decodedValue) != 32 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("value is not a base64url of a SHA256 digest"))
return
}

if err := testAddresses(r.Context(), peerID, typedBody.Addresses); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(fmt.Sprintf("error testing addresses: %s", err)))
return
}
const ttl = time.Hour
err = c.Datastore.PutWithTTL(r.Context(), datastore.NewKey(peerID.String()), []byte(typedBody.Value), ttl)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(fmt.Sprintf("error storing value: %s", err)))
return
}
w.WriteHeader(http.StatusOK)
},
}

const ttl = time.Hour
err = c.Datastore.PutWithTTL(r.Context(), datastore.NewKey(peerID.String()), []byte(typedBody.Value), ttl)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(fmt.Sprintf("error storing value: %s", err)))
return
}
w.WriteHeader(http.StatusOK)
}).Methods("POST")
c.mux.Handle("/v1/_acme-challenge", authPeer).Methods("POST")

go func() { http.Serve(c.ln, c.mux) }()

Expand Down Expand Up @@ -239,41 +155,6 @@ type requestBody struct {
Addresses []string `json:"addresses"`
}

const signatureDomainString = "peer-forge-domain-challenge"

var signaturePayloadType []byte = []byte("/peer-forge-domain-challenge")

// makeUnsigned is a helper function that prepares a buffer to sign or verify.
// It returns a byte slice from a pool. The caller MUST return this slice to the
// pool.
func makeUnsigned(domain string, payloadType []byte, payload []byte) []byte {
var (
fields = [][]byte{[]byte(domain), payloadType, payload}

// fields are prefixed with their length as an unsigned varint. we
// compute the lengths before allocating the sig buffer, so we know how
// much space to add for the lengths
flen = make([][]byte, len(fields))
size = 0
)

for i, f := range fields {
l := len(f)
flen[i] = varint.ToUvarint(uint64(l))
size += l + len(flen[i])
}

b := pool.Get(size)

var s int
for i, f := range fields {
s += copy(b[s:], flen[i])
s += copy(b[s:], f)
}

return b[:s]
}

func (c *acmeWriter) OnFinalShutdown() error {
if !c.nlSetup {
return nil
Expand Down
2 changes: 1 addition & 1 deletion client/acme.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ func (d *dns01P2PForgeSolver) Wait(ctx context.Context, challenge acme.Challenge
}

func (d *dns01P2PForgeSolver) Present(ctx context.Context, challenge acme.Challenge) error {
return SendChallenge(ctx, d.forge, d.host.ID(), d.host.Peerstore().PrivKey(d.host.ID()), challenge.DNS01KeyAuthorization(), d.host.Addrs())
return SendChallenge(ctx, d.forge, d.host.Peerstore().PrivKey(d.host.ID()), challenge.DNS01KeyAuthorization(), d.host.Addrs())
}

func (d *dns01P2PForgeSolver) CleanUp(ctx context.Context, challenge acme.Challenge) error {
Expand Down
Loading

0 comments on commit e4350da

Please sign in to comment.