client/tailscale, tsnet, ipn/ipnlocal: prove nodekey ownership over noise

Fixes #5972

Change-Id: Ic33a93d3613ac5dbf172d6a8a459ca06a7f9e547
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2022-11-01 20:37:13 -07:00 committed by Brad Fitzpatrick
parent 8c790207a0
commit 910db02652
7 changed files with 117 additions and 45 deletions

View File

@ -18,9 +18,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"tailscale.com/types/key"
)
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
@ -93,29 +90,6 @@ func (c *Client) setAuth(r *http.Request) {
}
}
// nodeKeyAuth is an AuthMethod for NewClient that authenticates requests
// using a node key over the Noise protocol.
type nodeKeyAuth key.NodePublic
func (k nodeKeyAuth) modifyRequest(req *http.Request) {
// QueryEscape the node key since it has a colon in it.
nk := url.QueryEscape(key.NodePublic(k).String())
req.SetBasicAuth(nk, "")
}
// NewNoiseClient is a convenience method for instantiating a new Client
// that uses the Noise protocol for authentication.
//
// tailnet is the globally unique identifier for a Tailscale network, such
// as "example.com" or "user@gmail.com".
func NewNoiseClient(tailnet string, noiseRoundTripper http.RoundTripper, nk key.NodePublic) *Client {
return &Client{
tailnet: tailnet,
auth: nodeKeyAuth(nk),
HTTPClient: &http.Client{Transport: noiseRoundTripper},
}
}
// NewClient is a convenience method for instantiating a new Client.
//
// tailnet is the globally unique identifier for a Tailscale network, such

View File

@ -730,3 +730,13 @@ func (c *Auto) SetDNS(ctx context.Context, req *tailcfg.SetDNSRequest) error {
func (c *Auto) DoNoiseRequest(req *http.Request) (*http.Response, error) {
return c.direct.DoNoiseRequest(req)
}
// GetSingleUseNoiseRoundTripper returns a RoundTripper that can be only be used
// once (and must be used once) to make a single HTTP request over the noise
// channel to the coordination server.
//
// In addition to the RoundTripper, it returns the HTTP/2 channel's early noise
// payload, if any.
func (c *Auto) GetSingleUseNoiseRoundTripper(ctx context.Context) (http.RoundTripper, *tailcfg.EarlyNoise, error) {
return c.direct.GetSingleUseNoiseRoundTripper(ctx)
}

View File

@ -1607,6 +1607,33 @@ func (c *Direct) DoNoiseRequest(req *http.Request) (*http.Response, error) {
return nc.Do(req)
}
// GetSingleUseNoiseRoundTripper returns a RoundTripper that can be only be used
// once (and must be used once) to make a single HTTP request over the noise
// channel to the coordination server.
//
// In addition to the RoundTripper, it returns the HTTP/2 channel's early noise
// payload, if any.
func (c *Direct) GetSingleUseNoiseRoundTripper(ctx context.Context) (http.RoundTripper, *tailcfg.EarlyNoise, error) {
nc, err := c.getNoiseClient()
if err != nil {
return nil, nil, err
}
for tries := 0; tries < 3; tries++ {
conn, err := nc.getConn(ctx)
if err != nil {
return nil, nil, err
}
earlyPayloadMaybeNil, err := conn.getEarlyPayload(ctx)
if err != nil {
return nil, nil, err
}
if conn.h2cc.ReserveNewRequest() {
return conn, earlyPayloadMaybeNil, nil
}
}
return nil, nil, errors.New("[unexpected] failed to reserve a request on a connection")
}
// doPingerPing sends a Ping to pr.IP using pinger, and sends an http request back to
// pr.URL with ping response data.
func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinger Pinger, pingType tailcfg.PingType) {

View File

@ -42,12 +42,24 @@ type noiseConn struct {
reader io.Reader // (effectively Conn.Reader after header)
earlyPayloadReady chan struct{} // closed after earlyPayload is set (including set to nil)
earlyPayload *tailcfg.EarlyNoise
earlyPayloadErr error
}
func (c *noiseConn) RoundTrip(r *http.Request) (*http.Response, error) {
return c.h2cc.RoundTrip(r)
}
// getEarlyPayload waits for the early noise payload to arrive.
// It may return (nil, nil) if the server begins HTTP/2 without one.
func (c *noiseConn) getEarlyPayload(ctx context.Context) (*tailcfg.EarlyNoise, error) {
select {
case <-c.earlyPayloadReady:
return c.earlyPayload, c.earlyPayloadErr
case <-ctx.Done():
return nil, ctx.Err()
}
}
// The first 9 bytes from the server to client over Noise are either an HTTP/2
// settings frame (a normal HTTP/2 setup) or, as we added later, an "early payload"
// header that's also 9 bytes long: 5 bytes (earlyPayloadMagic) followed by 4 bytes
@ -80,33 +92,38 @@ func (c *noiseConn) Read(p []byte) (n int, err error) {
// c.earlyPayload, closing c.earlyPayloadReady, and initializing c.reader for
// future reads.
func (c *noiseConn) readHeader() {
defer close(c.earlyPayloadReady)
setErr := func(err error) {
c.reader = returnErrReader{err}
c.earlyPayloadErr = err
}
var hdr [hdrLen]byte
if _, err := io.ReadFull(c.Conn, hdr[:]); err != nil {
c.reader = returnErrReader{err}
setErr(err)
return
}
if string(hdr[:len(earlyPayloadMagic)]) != earlyPayloadMagic {
// No early payload. We have to return the 9 bytes read we already
// consumed.
close(c.earlyPayloadReady)
c.reader = io.MultiReader(bytes.NewReader(hdr[:]), c.Conn)
return
}
epLen := binary.BigEndian.Uint32(hdr[len(earlyPayloadMagic):])
if epLen > 10<<20 {
c.reader = returnErrReader{errors.New("invalid early payload length")}
setErr(errors.New("invalid early payload length"))
return
}
payBuf := make([]byte, epLen)
if _, err := io.ReadFull(c.Conn, payBuf); err != nil {
c.reader = returnErrReader{err}
setErr(err)
return
}
if err := json.Unmarshal(payBuf, &c.earlyPayload); err != nil {
c.reader = returnErrReader{err}
setErr(err)
return
}
close(c.earlyPayloadReady)
c.reader = c.Conn
}

View File

@ -6,12 +6,14 @@ package ipnlocal
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"os/user"
"path/filepath"
@ -3765,18 +3767,60 @@ func (b *LocalBackend) magicConn() (*magicsock.Conn, error) {
return mc, nil
}
type noiseRoundTripper struct {
*LocalBackend
type keyProvingNoiseRoundTripper struct {
b *LocalBackend
}
func (n noiseRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return n.LocalBackend.DoNoiseRequest(req)
func (n keyProvingNoiseRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
b := n.b
var priv key.NodePrivate
b.mu.Lock()
cc := b.ccAuto
if nm := b.netMap; nm != nil {
priv = nm.PrivateKey
}
b.mu.Unlock()
if cc == nil {
return nil, errors.New("no client")
}
if priv.IsZero() {
return nil, errors.New("no netmap or private key")
}
rt, ep, err := cc.GetSingleUseNoiseRoundTripper(req.Context())
if err != nil {
return nil, err
}
if ep == nil || ep.NodeKeyChallenge.IsZero() {
go rt.RoundTrip(new(http.Request)) // return our reservation with a bogus request
return nil, errors.New("this coordination server does not support API calls over the Noise channel")
}
// QueryEscape the node key since it has a colon in it.
nk := url.QueryEscape(priv.Public().String())
req.SetBasicAuth(nk, "")
// genNodeProofHeaderValue returns the Tailscale-Node-Proof header's value to prove
// to chalPub that we control claimedPrivate.
genNodeProofHeaderValue := func(claimedPrivate key.NodePrivate, chalPub key.ChallengePublic) string {
// TODO(bradfitz): cache this somewhere?
box := claimedPrivate.SealToChallenge(chalPub, []byte(chalPub.String()))
return claimedPrivate.Public().String() + " " + base64.StdEncoding.EncodeToString(box)
}
// And prove we have the private key corresponding to the public key sent
// tin the basic auth username.
req.Header.Set("Tailscale-Node-Proof", genNodeProofHeaderValue(priv, ep.NodeKeyChallenge))
return rt.RoundTrip(req)
}
// NoiseRoundTripper returns an http.RoundTripper that uses the LocalBackend's
// DoNoiseRequest method.
func (b *LocalBackend) NoiseRoundTripper() http.RoundTripper {
return noiseRoundTripper{b}
// KeyProvingNoiseRoundTripper returns an http.RoundTripper that uses the LocalBackend's
// DoNoiseRequest method and mutates the request to add an authorization header
// to prove the client's nodekey.
func (b *LocalBackend) KeyProvingNoiseRoundTripper() http.RoundTripper {
return keyProvingNoiseRoundTripper{b}
}
// DoNoiseRequest sends a request to URL over the control plane

View File

@ -493,11 +493,8 @@ func (s *Server) APIClient() (*tailscale.Client, error) {
return nil, err
}
nm := s.lb.NetMap()
if nm == nil {
return nil, errors.New("no netmap, not logged in?")
}
c := tailscale.NewNoiseClient(nm.Domain, s.lb.NoiseRoundTripper(), nm.NodeKey)
c := tailscale.NewClient("-", nil)
c.HTTPClient = &http.Client{Transport: s.lb.KeyProvingNoiseRoundTripper()}
return c, nil
}

View File

@ -82,3 +82,6 @@ func (k ChallengePublic) MarshalText() ([]byte, error) {
func (k *ChallengePublic) UnmarshalText(b []byte) error {
return parseHex(k.k[:], mem.B(b), mem.S(chalPublicHexPrefix))
}
// IsZero reports whether k is the zero value.
func (k ChallengePublic) IsZero() bool { return k == ChallengePublic{} }