client,cmd/tailscale,ipn,tka,types: implement tka initialization flow

This PR implements the client-side of initializing network-lock with the
Coordination server.

Signed-off-by: Tom DNetto <tom@tailscale.com>
This commit is contained in:
Tom DNetto
2022-08-11 10:43:09 -07:00
committed by Tom
parent 18edd79421
commit facafd8819
18 changed files with 514 additions and 13 deletions

View File

@@ -0,0 +1,226 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package ipnlocal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"tailscale.com/envknob"
"tailscale.com/ipn/ipnstate"
"tailscale.com/logtail/backoff"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
)
var networkLockAvailable = envknob.Bool("TS_EXPERIMENTAL_NETWORK_LOCK")
// CanSupportNetworkLock returns true if tailscaled is able to operate
// a local tailnet key authority (and hence enforce network lock).
func (b *LocalBackend) CanSupportNetworkLock() bool {
if b.tka != nil {
// The TKA is being used, so yeah its supported.
return true
}
if b.TailscaleVarRoot() != "" {
// Theres a var root (aka --statedir), so if network lock gets
// initialized we have somewhere to store our AUMs. Thats all
// we need.
return true
}
return false
}
// NetworkLockStatus returns a structure describing the state of the
// tailnet key authority, if any.
func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
if b.tka == nil {
return &ipnstate.NetworkLockStatus{
Enabled: false,
PublicKey: b.nlPrivKey.Public(),
}
}
var head [32]byte
h := b.tka.Head()
copy(head[:], h[:])
return &ipnstate.NetworkLockStatus{
Enabled: true,
Head: &head,
PublicKey: b.nlPrivKey.Public(),
}
}
// NetworkLockInit enables network-lock for the tailnet, with the tailnets'
// key authority initialized to trust the provided keys.
//
// Initialization involves two RPCs with control, termed 'begin' and 'finish'.
// The Begin RPC transmits the genesis Authority Update Message, which
// encodes the initial state of the authority, and the list of all nodes
// needing signatures is returned as a response.
// The Finish RPC submits signatures for all these nodes, at which point
// Control has everything it needs to atomically enable network lock.
func (b *LocalBackend) NetworkLockInit(keys []tka.Key) error {
if b.tka != nil {
return errors.New("network-lock is already initialized")
}
if !networkLockAvailable {
return errors.New("this is an experimental feature in your version of tailscale - Please upgrade to the latest to use this.")
}
if !b.CanSupportNetworkLock() {
return errors.New("network-lock is not supported in this configuration. Did you supply a --statedir?")
}
// Generates a genesis AUM representing trust in the provided keys.
// We use an in-memory tailchonk because we don't want to commit to
// the filesystem until we've finished the initialization sequence,
// just in case something goes wrong.
_, genesisAUM, err := tka.Create(&tka.Mem{}, tka.State{
Keys: keys,
// TODO(tom): Actually plumb a real disablement value.
DisablementSecrets: [][]byte{bytes.Repeat([]byte{1}, 32)},
}, b.nlPrivKey)
if err != nil {
return fmt.Errorf("tka.Create: %v", err)
}
b.logf("Generated genesis AUM to initialize network lock, trusting the following keys:")
for i, k := range genesisAUM.State.Keys {
b.logf(" - key[%d] = nlpub:%x with %d votes", i, k.Public, k.Votes)
}
// Phase 1/2 of initialization: Transmit the genesis AUM to Control.
initResp, err := b.tkaInitBegin(genesisAUM)
if err != nil {
return fmt.Errorf("tka init-begin RPC: %w", err)
}
// Our genesis AUM was accepted but before Control turns on enforcement of
// node-key signatures, we need to sign keys for all the existing nodes.
// If we don't get these signatures ahead of time, everyone will loose
// connectivity because control won't have any signatures to send which
// satisfy network-lock checks.
var sigs []tkatype.MarshaledSignature
for _, nkp := range initResp.NeedSignatures {
nks, err := signNodeKey(nkp, b.nlPrivKey)
if err != nil {
return fmt.Errorf("generating signature: %v", err)
}
sigs = append(sigs, nks.Serialize())
}
// Finalize enablement by transmitting signature for all nodes to Control.
_, err = b.tkaInitFinish(sigs)
return err
}
func signNodeKey(nk key.NodePublic, signer key.NLPrivate) (*tka.NodeKeySignature, error) {
p, err := nk.MarshalBinary()
if err != nil {
return nil, err
}
sig := tka.NodeKeySignature{
SigKind: tka.SigDirect,
KeyID: signer.KeyID(),
Pubkey: p,
}
sig.Signature, err = signer.SignNKS(sig.SigHash())
if err != nil {
return nil, fmt.Errorf("signature failed: %w", err)
}
return &sig, nil
}
func (b *LocalBackend) tkaInitBegin(aum tka.AUM) (*tailcfg.TKAInitBeginResponse, error) {
var req bytes.Buffer
if err := json.NewEncoder(&req).Encode(tailcfg.TKAInitBeginRequest{
GenesisAUM: aum.Serialize(),
}); err != nil {
return nil, fmt.Errorf("encoding request: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
bo := backoff.NewBackoff("tka-init-begin", b.logf, 5*time.Second)
for {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("ctx: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/init/begin", &req)
if err != nil {
return nil, fmt.Errorf("req: %w", err)
}
res, err := b.DoNoiseRequest(req)
if err != nil {
bo.BackOff(ctx, err)
continue
}
if res.StatusCode != 200 {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKAInitBeginResponse)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
}
return a, nil
}
}
func (b *LocalBackend) tkaInitFinish(nks []tkatype.MarshaledSignature) (*tailcfg.TKAInitFinishResponse, error) {
var req bytes.Buffer
if err := json.NewEncoder(&req).Encode(tailcfg.TKAInitFinishRequest{
Signatures: nks,
}); err != nil {
return nil, fmt.Errorf("encoding request: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
bo := backoff.NewBackoff("tka-init-finish", b.logf, 5*time.Second)
for {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("ctx: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/init/finish", &req)
if err != nil {
return nil, fmt.Errorf("req: %w", err)
}
res, err := b.DoNoiseRequest(req)
if err != nil {
bo.BackOff(ctx, err)
continue
}
if res.StatusCode != 200 {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKAInitFinishResponse)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
}
return a, nil
}
}

View File

@@ -67,6 +67,21 @@ type Status struct {
User map[tailcfg.UserID]tailcfg.UserProfile
}
// NetworkLockStatus represents whether network-lock is enabled,
// along with details about the locally-known state of the tailnet
// key authority.
type NetworkLockStatus struct {
// Enabled is true if network lock is enabled.
Enabled bool
// Head describes the AUM hash of the leaf AUM. Head is nil
// if network lock is not enabled.
Head *[32]byte
// PublicKey describes the nodes' network-lock public key.
PublicKey key.NLPublic
}
// TailnetStatus is information about a Tailscale network ("tailnet").
type TailnetStatus struct {
// Name is the name of the network that's currently in use.

View File

@@ -31,6 +31,7 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/version"
@@ -150,6 +151,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.serveIDToken(w, r)
case "/localapi/v0/upload-client-metrics":
h.serveUploadClientMetrics(w, r)
case "/localapi/v0/tka/status":
h.serveTkaStatus(w, r)
case "/localapi/v0/tka/init":
h.serveTkaInit(w, r)
case "/":
io.WriteString(w, "tailscaled\n")
default:
@@ -791,6 +796,58 @@ func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Reques
json.NewEncoder(w).Encode(struct{}{})
}
func (h *Handler) serveTkaStatus(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "lock status access denied", http.StatusForbidden)
return
}
if r.Method != http.MethodGet {
http.Error(w, "use Get", http.StatusMethodNotAllowed)
return
}
j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
if err != nil {
http.Error(w, "JSON encoding error", 500)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(j)
}
func (h *Handler) serveTkaInit(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "lock init access denied", http.StatusForbidden)
return
}
if r.Method != http.MethodPost {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
type initRequest struct {
Keys []tka.Key
}
var req initRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON body", 400)
return
}
if err := h.b.NetworkLockInit(req.Keys); err != nil {
http.Error(w, "initialization failed: "+err.Error(), http.StatusInternalServerError)
return
}
j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
if err != nil {
http.Error(w, "JSON encoding error", 500)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(j)
}
func defBool(a string, def bool) bool {
if a == "" {
return def