all: implement lock revoke-keys command

The revoke-keys command allows nodes with tailnet lock keys
to collaborate to erase the use of a compromised key, and remove trust
in it.

Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates ENG-1848
This commit is contained in:
Tom DNetto
2023-07-18 15:13:36 -07:00
committed by Tom
parent 7adf15f90e
commit 767e839db5
7 changed files with 709 additions and 3 deletions

View File

@@ -845,6 +845,93 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
return resp.Signatures, nil
}
// NetworkLockGenerateRecoveryAUM generates an AUM which retroactively removes trust in the
// specified keys. This AUM is signed by the current node and returned.
//
// If forkFrom is specified, it is used as the parent AUM to fork from. If the zero value,
// the parent AUM is determined automatically.
func (b *LocalBackend) NetworkLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
nlPriv = p.Persist().NetworkLockKey()
}
if nlPriv.IsZero() {
return nil, errMissingNetmap
}
aum, err := b.tka.authority.MakeRetroactiveRevocation(b.tka.storage, removeKeys, nlPriv.KeyID(), forkFrom)
if err != nil {
return nil, err
}
// Sign it ourselves.
aum.Signatures, err = nlPriv.SignAUM(aum.SigHash())
if err != nil {
return nil, fmt.Errorf("signing failed: %w", err)
}
return aum, nil
}
// NetworkLockCosignRecoveryAUM co-signs the provided recovery AUM and returns
// the updated structure.
//
// The recovery AUM provided should be the output from a previous call to
// NetworkLockGenerateRecoveryAUM or NetworkLockCosignRecoveryAUM.
func (b *LocalBackend) NetworkLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
nlPriv = p.Persist().NetworkLockKey()
}
if nlPriv.IsZero() {
return nil, errMissingNetmap
}
for _, sig := range aum.Signatures {
if bytes.Equal(sig.KeyID, nlPriv.KeyID()) {
return nil, errors.New("this node has already signed this recovery AUM")
}
}
// Sign it ourselves.
sigs, err := nlPriv.SignAUM(aum.SigHash())
if err != nil {
return nil, fmt.Errorf("signing failed: %w", err)
}
aum.Signatures = append(aum.Signatures, sigs...)
return aum, nil
}
func (b *LocalBackend) NetworkLockSubmitRecoveryAUM(aum *tka.AUM) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return errNetworkLockNotActive
}
var ourNodeKey key.NodePublic
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() && !p.Persist().PrivateNodeKey().IsZero() {
ourNodeKey = p.Persist().PublicNodeKey()
}
if ourNodeKey.IsZero() {
return errors.New("no node-key: is tailscale logged in?")
}
b.mu.Unlock()
_, err := b.tkaDoSyncSend(ourNodeKey, aum.Hash(), []tka.AUM{*aum}, false)
b.mu.Lock()
return err
}
var tkaSuffixEncoder = base64.RawStdEncoding
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to

View File

@@ -994,3 +994,129 @@ func TestTKAAffectedSigs(t *testing.T) {
})
}
}
func TestTKARecoverCompromisedKeyFlow(t *testing.T) {
nodePriv := key.NewNode()
nlPriv := key.NewNLPrivate()
cosignPriv := key.NewNLPrivate()
compromisedPriv := key.NewNLPrivate()
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
must.Do(pm.SetPrefs((&ipn.Prefs{
Persist: &persist.Persist{
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View()))
// Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
cosignKey := tka.Key{Kind: tka.Key25519, Public: cosignPriv.Public().Verifier(), Votes: 2}
compromisedKey := tka.Key{Kind: tka.Key25519, Public: compromisedPriv.Public().Verifier(), Votes: 1}
temp := t.TempDir()
tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID))
os.Mkdir(tkaPath, 0755)
chonk, err := tka.ChonkDir(tkaPath)
if err != nil {
t.Fatal(err)
}
authority, _, err := tka.Create(chonk, tka.State{
Keys: []tka.Key{key, compromisedKey, cosignKey},
DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)},
}, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/sync/send":
body := new(tailcfg.TKASyncSendRequest)
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
t.Fatal(err)
}
t.Logf("got sync send:\n%+v", body)
var remoteHead tka.AUMHash
if err := remoteHead.UnmarshalText([]byte(body.Head)); err != nil {
t.Fatalf("head unmarshal: %v", err)
}
toApply := make([]tka.AUM, len(body.MissingAUMs))
for i, a := range body.MissingAUMs {
if err := toApply[i].Unserialize(a); err != nil {
t.Fatalf("decoding missingAUM[%d]: %v", i, err)
}
}
// Apply the recovery AUM to an authority to make sure it works.
if err := authority.Inform(chonk, toApply); err != nil {
t.Errorf("recovery AUM could not be applied: %v", err)
}
// Make sure the key we removed isn't trusted.
if authority.KeyTrusted(compromisedPriv.KeyID()) {
t.Error("compromised key was not removed from tka")
}
w.WriteHeader(200)
if err := json.NewEncoder(w).Encode(tailcfg.TKASubmitSignatureResponse{}); err != nil {
t.Fatal(err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
cc := fakeControlClient(t, client)
b := LocalBackend{
varRoot: temp,
cc: cc,
ccAuto: cc,
logf: t.Logf,
tka: &tkaState{
authority: authority,
storage: chonk,
},
pm: pm,
store: pm.Store(),
}
aum, err := b.NetworkLockGenerateRecoveryAUM([]tkatype.KeyID{compromisedPriv.KeyID()}, tka.AUMHash{})
if err != nil {
t.Fatalf("NetworkLockGenerateRecoveryAUM() failed: %v", err)
}
// Cosign using the cosigning key.
{
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
must.Do(pm.SetPrefs((&ipn.Prefs{
Persist: &persist.Persist{
PrivateNodeKey: nodePriv,
NetworkLockKey: cosignPriv,
},
}).View()))
b := LocalBackend{
varRoot: temp,
logf: t.Logf,
tka: &tkaState{
authority: authority,
storage: chonk,
},
pm: pm,
store: pm.Store(),
}
if aum, err = b.NetworkLockCosignRecoveryAUM(aum); err != nil {
t.Fatalf("NetworkLockCosignRecoveryAUM() failed: %v", err)
}
}
// Finally, submit the recovery AUM. Validation is done
// in the fake control handler.
if err := b.NetworkLockSubmitRecoveryAUM(aum); err != nil {
t.Errorf("NetworkLockSubmitRecoveryAUM() failed: %v", err)
}
}

View File

@@ -44,6 +44,7 @@ import (
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/ptr"
"tailscale.com/types/tkatype"
"tailscale.com/util/clientmetric"
"tailscale.com/util/httpm"
"tailscale.com/util/mak"
@@ -106,6 +107,9 @@ var handler = map[string]localAPIHandler{
"tka/affected-sigs": (*Handler).serveTKAAffectedSigs,
"tka/wrap-preauth-key": (*Handler).serveTKAWrapPreauthKey,
"tka/verify-deeplink": (*Handler).serveTKAVerifySigningDeeplink,
"tka/generate-recovery-aum": (*Handler).serveTKAGenerateRecoveryAUM,
"tka/cosign-recovery-aum": (*Handler).serveTKACosignRecoveryAUM,
"tka/submit-recovery-aum": (*Handler).serveTKASubmitRecoveryAUM,
"upload-client-metrics": (*Handler).serveUploadClientMetrics,
"watch-ipn-bus": (*Handler).serveWatchIPNBus,
"whois": (*Handler).serveWhoIs,
@@ -1747,6 +1751,103 @@ func (h *Handler) serveTKAAffectedSigs(w http.ResponseWriter, r *http.Request) {
w.Write(j)
}
func (h *Handler) serveTKAGenerateRecoveryAUM(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
type verifyRequest struct {
Keys []tkatype.KeyID
ForkFrom string
}
var req verifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON for verifyRequest body", http.StatusBadRequest)
return
}
var forkFrom tka.AUMHash
if req.ForkFrom != "" {
if err := forkFrom.UnmarshalText([]byte(req.ForkFrom)); err != nil {
http.Error(w, "decoding fork-from: "+err.Error(), http.StatusBadRequest)
return
}
}
res, err := h.b.NetworkLockGenerateRecoveryAUM(req.Keys, forkFrom)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(res.Serialize())
}
func (h *Handler) serveTKACosignRecoveryAUM(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
body := io.LimitReader(r.Body, 1024*1024)
aumBytes, err := ioutil.ReadAll(body)
if err != nil {
http.Error(w, "reading AUM", http.StatusBadRequest)
return
}
var aum tka.AUM
if err := aum.Unserialize(aumBytes); err != nil {
http.Error(w, "decoding AUM", http.StatusBadRequest)
return
}
res, err := h.b.NetworkLockCosignRecoveryAUM(&aum)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(res.Serialize())
}
func (h *Handler) serveTKASubmitRecoveryAUM(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
body := io.LimitReader(r.Body, 1024*1024)
aumBytes, err := ioutil.ReadAll(body)
if err != nil {
http.Error(w, "reading AUM", http.StatusBadRequest)
return
}
var aum tka.AUM
if err := aum.Unserialize(aumBytes); err != nil {
http.Error(w, "decoding AUM", http.StatusBadRequest)
return
}
if err := h.b.NetworkLockSubmitRecoveryAUM(&aum); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// serveProfiles serves profile switching-related endpoints. Supported methods
// and paths are:
// - GET /profiles/: list all profiles (JSON-encoded array of ipn.LoginProfiles)