all: implement preauth-key support with tailnet lock

Signed-off-by: Tom DNetto <tom@tailscale.com>
This commit is contained in:
Tom DNetto 2023-03-02 13:24:26 -08:00 committed by Tom
parent f4f8ed98d9
commit ce99474317
7 changed files with 251 additions and 3 deletions

View File

@ -850,6 +850,30 @@ func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key, disa
return decodeJSON[*ipnstate.NetworkLockStatus](body)
}
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
// enable unattended bringup in the locked tailnet.
func (lc *LocalClient) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
encodedPrivate, err := tkaKey.MarshalText()
if err != nil {
return "", err
}
var b bytes.Buffer
type wrapRequest struct {
TSKey string
TKAKey string // key.NLPrivate.MarshalText
}
if err := json.NewEncoder(&b).Encode(wrapRequest{TSKey: preauthKey, TKAKey: string(encodedPrivate)}); err != nil {
return "", err
}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/wrap-preauth-key", 200, &b)
if err != nil {
return "", fmt.Errorf("error: %w", err)
}
return string(body), nil
}
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
func (lc *LocalClient) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
var b bytes.Buffer

View File

@ -15,6 +15,7 @@ import (
"os"
"strconv"
"strings"
"time"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
@ -39,6 +40,7 @@ var netlockCmd = &ffcli.Command{
nlDisablementKDFCmd,
nlLogCmd,
nlLocalDisableCmd,
nlTskeyWrapCmd,
},
Exec: runNetworkLockStatus,
}
@ -622,3 +624,60 @@ func runNetworkLockLog(ctx context.Context, args []string) error {
}
return nil
}
var nlTskeyWrapCmd = &ffcli.Command{
Name: "tskey-wrap",
ShortUsage: "tskey-wrap <tailscale pre-auth key>",
ShortHelp: "Modifies a pre-auth key from the admin panel to work with tailnet lock",
LongHelp: "Modifies a pre-auth key from the admin panel to work with tailnet lock",
Exec: runTskeyWrapCmd,
}
func runTskeyWrapCmd(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("usage: lock tskey-wrap <tailscale pre-auth key>")
}
if strings.Contains(args[0], "--TL") {
return errors.New("Error: provided key was already wrapped")
}
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
// Generate a separate tailnet-lock key just for the credential signature.
// We use the free-form meta strings to mark a little bit of metadata about this
// key.
priv := key.NewNLPrivate()
m := map[string]string{
"purpose": "pre-auth key",
"wrapper_stableid": string(st.Self.ID),
"wrapper_createtime": fmt.Sprint(time.Now().Unix()),
}
if strings.HasPrefix(args[0], "tskey-auth-") && strings.Index(args[0][len("tskey-auth-"):], "-") > 0 {
// We don't want to accidentally embed the nonce part of the authkey in
// the event the format changes. As such, we make sure its in the format we
// expect (tskey-auth-<stableID, inc CNTRL suffix>-nonce) before we parse
// out and embed the stableID.
s := strings.TrimPrefix(args[0], "tskey-auth-")
m["authkey_stableid"] = s[:strings.Index(s, "-")]
}
k := tka.Key{
Kind: tka.Key25519,
Public: priv.Public().Verifier(),
Votes: 1,
Meta: m,
}
wrapped, err := localClient.NetworkLockWrapPreauthKey(ctx, args[0], priv)
if err != nil {
return fmt.Errorf("wrapping failed: %w", err)
}
if err := localClient.NetworkLockModify(ctx, []tka.Key{k}, nil); err != nil {
return fmt.Errorf("add key failed: %w", err)
}
fmt.Println(wrapped)
return nil
}

View File

@ -86,10 +86,9 @@ func TestQnapAuthnURL(t *testing.T) {
},
{
name: "err != nil",
in: "http://192.168.0.%31/",
in: "http://192.168.0.%31/",
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -7,6 +7,8 @@ import (
"bufio"
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
@ -424,7 +426,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
tryingNewKey := c.tryingNewKey
serverKey := c.serverKey
serverNoiseKey := c.serverNoiseKey
authKey := c.authKey
authKey, isWrapped, wrappedSig, wrappedKey := decodeWrappedAuthkey(c.authKey, c.logf)
hi := c.hostInfoLocked()
backendLogID := hi.BackendLogID
expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.timeNow())
@ -510,6 +512,22 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
if nodeKeySignature, err = resignNKS(persist.NetworkLockKey, tryingNewKey.Public(), opt.OldNodeKeySignature); err != nil {
c.logf("Failed re-signing node-key signature: %v", err)
}
} else if isWrapped {
// We were given a wrapped pre-auth key, which means that in addition
// to being a regular pre-auth key there was a suffix with information to
// generate a tailnet-lock signature.
nk, err := tryingNewKey.Public().MarshalBinary()
if err != nil {
return false, "", nil, fmt.Errorf("marshalling node-key: %w", err)
}
sig := &tka.NodeKeySignature{
SigKind: tka.SigRotation,
Pubkey: nk,
Nested: wrappedSig,
}
sigHash := sig.SigHash()
sig.Signature = ed25519.Sign(wrappedKey, sigHash[:])
nodeKeySignature = sig.Serialize()
}
if backendLogID == "" {
@ -1713,6 +1731,43 @@ func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
res.Body.Close()
}
// decodeWrappedAuthkey separates wrapping information from an authkey, if any.
// In all cases the authkey is returned, sans wrapping information if any.
//
// If the authkey is wrapped, isWrapped returns true, along with the wrapping signature
// and private key.
func decodeWrappedAuthkey(key string, logf logger.Logf) (authKey string, isWrapped bool, sig *tka.NodeKeySignature, priv ed25519.PrivateKey) {
authKey, suffix, found := strings.Cut(key, "--TL")
if !found {
return key, false, nil, nil
}
sigBytes, privBytes, found := strings.Cut(suffix, "-")
if !found {
logf("decoding wrapped auth-key: did not find delimiter")
return key, false, nil, nil
}
rawSig, err := base64.RawStdEncoding.DecodeString(sigBytes)
if err != nil {
logf("decoding wrapped auth-key: signature decode: %v", err)
return key, false, nil, nil
}
rawPriv, err := base64.RawStdEncoding.DecodeString(privBytes)
if err != nil {
logf("decoding wrapped auth-key: priv decode: %v", err)
return key, false, nil, nil
}
sig = new(tka.NodeKeySignature)
if err := sig.Unserialize([]byte(rawSig)); err != nil {
logf("decoding wrapped auth-key: signature: %v", err)
return key, false, nil, nil
}
priv = ed25519.PrivateKey(rawPriv)
return authKey, true, sig, priv
}
var (
metricMapRequestsActive = clientmetric.NewGauge("controlclient_map_requests_active")

View File

@ -4,6 +4,7 @@
package controlclient
import (
"crypto/ed25519"
"encoding/json"
"net/http"
"net/http/httptest"
@ -142,3 +143,42 @@ func TestTsmpPing(t *testing.T) {
t.Fatal(err)
}
}
func TestDecodeWrappedAuthkey(t *testing.T) {
k, isWrapped, sig, priv := decodeWrappedAuthkey("tskey-32mjsdkdsffds9o87dsfkjlh", nil)
if want := "tskey-32mjsdkdsffds9o87dsfkjlh"; k != want {
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).key = %q, want %q", k, want)
}
if isWrapped {
t.Error("decodeWrappedAuthkey(<unwrapped-key>).isWrapped = true, want false")
}
if sig != nil {
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).sig = %v, want nil", sig)
}
if priv != nil {
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).priv = %v, want nil", priv)
}
k, isWrapped, sig, priv = decodeWrappedAuthkey("tskey-auth-k7UagY1CNTRL-ZZZZZ--TLpAEDA1ggnXuw4/fWnNWUwcoOjLemhOvml1juMl5lhLmY5sBUsj8EWEAfL2gdeD9g8VDw5tgcxCiHGlEb67BgU2DlFzZApi4LheLJraA+pYjTGChVhpZz1iyiBPD+U2qxDQAbM3+WFY0EBlggxmVqG53Hu0Rg+KmHJFMlUhfgzo+AQP6+Kk9GzvJJOs4-k36RdoSFqaoARfQo0UncHAV0t3YTqrkD5r/z2jTrE43GZWobnce7RGD4qYckUyVSF+DOj4BA/r4qT0bO8kk6zg", nil)
if want := "tskey-auth-k7UagY1CNTRL-ZZZZZ"; k != want {
t.Errorf("decodeWrappedAuthkey(<wrapped-key>).key = %q, want %q", k, want)
}
if !isWrapped {
t.Error("decodeWrappedAuthkey(<wrapped-key>).isWrapped = false, want true")
}
if sig == nil {
t.Fatal("decodeWrappedAuthkey(<wrapped-key>).sig = nil, want non-nil signature")
}
sigHash := sig.SigHash()
if !ed25519.Verify(sig.KeyID, sigHash[:], sig.Signature) {
t.Error("signature failed to verify")
}
// Make sure the private is correct by using it.
someSig := ed25519.Sign(priv, []byte{1, 2, 3, 4})
if !ed25519.Verify(sig.WrappingPubkey, []byte{1, 2, 3, 4}, someSig) {
t.Error("failed to use priv")
}
}

View File

@ -6,7 +6,9 @@ package ipnlocal
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
@ -847,6 +849,40 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
return resp.Signatures, nil
}
var tkaSuffixEncoder = base64.RawStdEncoding
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
// enable unattended bringup in the locked tailnet.
//
// The provided trusted tailnet-lock key is used to sign
// a SigCredential structure, which is encoded along with the
// private key and appended to the pre-auth key.
func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.NLPrivate) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return "", errNetworkLockNotActive
}
pub, priv, err := ed25519.GenerateKey(nil) // nil == crypto/rand
if err != nil {
return "", err
}
sig := tka.NodeKeySignature{
SigKind: tka.SigCredential,
KeyID: tkaKey.KeyID(),
WrappingPubkey: pub,
}
sig.Signature, err = tkaKey.SignNKS(sig.SigHash())
if err != nil {
return "", fmt.Errorf("signing failed: %w", err)
}
b.logf("Generated network-lock credential signature using %s", tkaKey.Public().CLIString())
return fmt.Sprintf("%s--TL%s-%s", preauthKey, tkaSuffixEncoder.EncodeToString(sig.Serialize()), tkaSuffixEncoder.EncodeToString(priv)), nil
}
func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) {
p, err := nodeInfo.NodePublic.MarshalBinary()
if err != nil {

View File

@ -101,6 +101,7 @@ var handler = map[string]localAPIHandler{
"tka/disable": (*Handler).serveTKADisable,
"tka/force-local-disable": (*Handler).serveTKALocalDisable,
"tka/affected-sigs": (*Handler).serveTKAAffectedSigs,
"tka/wrap-preauth-key": (*Handler).serveTKAWrapPreauthKey,
"upload-client-metrics": (*Handler).serveUploadClientMetrics,
"watch-ipn-bus": (*Handler).serveWatchIPNBus,
"whois": (*Handler).serveWhoIs,
@ -1570,6 +1571,40 @@ func (h *Handler) serveTKAModify(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
func (h *Handler) serveTKAWrapPreauthKey(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "network-lock modify access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
type wrapRequest struct {
TSKey string
TKAKey string // key.NLPrivate.MarshalText
}
var req wrapRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 12*1024)).Decode(&req); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
var priv key.NLPrivate
if err := priv.UnmarshalText([]byte(req.TKAKey)); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
wrappedKey, err := h.b.NetworkLockWrapPreauthKey(req.TSKey, priv)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(200)
w.Write([]byte(wrappedKey))
}
func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "network-lock modify access denied", http.StatusForbidden)