mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-04 23:45:34 +00:00
f580f4484f
It doesn't make a ton of sense for disablement to be communicated as an AUM, because any failure in the AUM or chain mechanism will mean disablement wont function. Instead, tracking of the disablement secrets remains inside the state machine, but actual disablement and communication of the disablement secret is done by the caller. Signed-off-by: Tom DNetto <tom@tailscale.com>
330 lines
10 KiB
Go
330 lines
10 KiB
Go
// Copyright (c) 2022 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 tka
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"encoding/base32"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/fxamacker/cbor/v2"
|
|
"golang.org/x/crypto/blake2s"
|
|
"tailscale.com/types/tkatype"
|
|
)
|
|
|
|
// AUMHash represents the BLAKE2s digest of an Authority Update Message (AUM).
|
|
type AUMHash [blake2s.Size]byte
|
|
|
|
var base32StdNoPad = base32.StdEncoding.WithPadding(base32.NoPadding)
|
|
|
|
// String returns the AUMHash encoded as base32.
|
|
// This is suitable for use as a filename, and for storing in text-preferred media.
|
|
func (h AUMHash) String() string {
|
|
return base32StdNoPad.EncodeToString(h[:])
|
|
}
|
|
|
|
// UnmarshalText implements encoding.TextUnmarshaler.
|
|
func (h *AUMHash) UnmarshalText(text []byte) error {
|
|
if l := base32StdNoPad.DecodedLen(len(text)); l != len(h) {
|
|
return fmt.Errorf("tka.AUMHash.UnmarshalText: text wrong length: %d, want %d", l, len(text))
|
|
}
|
|
if _, err := base32StdNoPad.Decode(h[:], text); err != nil {
|
|
return fmt.Errorf("tka.AUMHash.UnmarshalText: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarshalText implements encoding.TextMarshaler.
|
|
func (h AUMHash) MarshalText() ([]byte, error) {
|
|
b := make([]byte, base32StdNoPad.EncodedLen(len(h)))
|
|
base32StdNoPad.Encode(b, h[:])
|
|
return b, nil
|
|
}
|
|
|
|
// AUMKind describes valid AUM types.
|
|
type AUMKind uint8
|
|
|
|
// Valid AUM types. Do NOT reorder.
|
|
const (
|
|
AUMInvalid AUMKind = iota
|
|
// An AddKey AUM describes a new key trusted by the TKA.
|
|
//
|
|
// Only the Key optional field may be set.
|
|
AUMAddKey
|
|
// A RemoveKey AUM describes hte removal of a key trusted by TKA.
|
|
//
|
|
// Only the KeyID optional field may be set.
|
|
AUMRemoveKey
|
|
// A NoOp AUM carries no information and is used in tests.
|
|
AUMNoOp
|
|
// A UpdateKey AUM updates the metadata or votes of an existing key.
|
|
//
|
|
// Only KeyID, along with either/or Meta or Votes optional fields
|
|
// may be set.
|
|
AUMUpdateKey
|
|
// A Checkpoint AUM specifies the full state of the TKA.
|
|
//
|
|
// Only the State optional field may be set.
|
|
AUMCheckpoint
|
|
)
|
|
|
|
func (k AUMKind) String() string {
|
|
switch k {
|
|
case AUMInvalid:
|
|
return "invalid"
|
|
case AUMAddKey:
|
|
return "add-key"
|
|
case AUMRemoveKey:
|
|
return "remove-key"
|
|
case AUMNoOp:
|
|
return "no-op"
|
|
case AUMCheckpoint:
|
|
return "checkpoint"
|
|
case AUMUpdateKey:
|
|
return "update-key"
|
|
default:
|
|
return fmt.Sprintf("AUM?<%d>", int(k))
|
|
}
|
|
}
|
|
|
|
// AUM describes an Authority Update Message.
|
|
//
|
|
// The rules for adding new types of AUMs (MessageKind):
|
|
// - CBOR key IDs must never be changed.
|
|
// - New AUM types must not change semantics that are manipulated by other
|
|
// AUM types.
|
|
// - The serialization of existing data cannot change (in other words, if
|
|
// an existing serialization test in aum_test.go fails, you need to try a
|
|
// different approach).
|
|
//
|
|
// The rules for adding new fields are as follows:
|
|
// - Must all be optional.
|
|
// - An unset value must not result in serialization overhead. This is
|
|
// necessary so the serialization of older AUMs stays the same.
|
|
// - New processing semantics of the new fields must be compatible with the
|
|
// behavior of old clients (which will ignore the field).
|
|
// - No floats!
|
|
type AUM struct {
|
|
MessageKind AUMKind `cbor:"1,keyasint"`
|
|
PrevAUMHash []byte `cbor:"2,keyasint"`
|
|
|
|
// Key encodes a public key to be added to the key authority.
|
|
// This field is used for AddKey AUMs.
|
|
Key *Key `cbor:"3,keyasint,omitempty"`
|
|
|
|
// KeyID references a public key which is part of the key authority.
|
|
// This field is used for RemoveKey and UpdateKey AUMs.
|
|
KeyID tkatype.KeyID `cbor:"4,keyasint,omitempty"`
|
|
|
|
// State describes the full state of the key authority.
|
|
// This field is used for Checkpoint AUMs.
|
|
State *State `cbor:"5,keyasint,omitempty"`
|
|
|
|
// Votes and Meta describe properties of a key in the key authority.
|
|
// These fields are used for UpdateKey AUMs.
|
|
Votes *uint `cbor:"6,keyasint,omitempty"`
|
|
Meta map[string]string `cbor:"7,keyasint,omitempty"`
|
|
|
|
// Signatures lists the signatures over this AUM.
|
|
// CBOR key 23 is the last key which can be encoded as a single byte.
|
|
Signatures []tkatype.Signature `cbor:"23,keyasint,omitempty"`
|
|
}
|
|
|
|
// StaticValidate returns a nil error if the AUM is well-formed.
|
|
func (a *AUM) StaticValidate() error {
|
|
if a.Key != nil {
|
|
if err := a.Key.StaticValidate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if a.PrevAUMHash != nil && len(a.PrevAUMHash) == 0 {
|
|
return errors.New("absent parent must be represented by a nil slice")
|
|
}
|
|
for i, sig := range a.Signatures {
|
|
if len(sig.KeyID) == 0 || len(sig.Signature) != ed25519.SignatureSize {
|
|
return fmt.Errorf("signature %d has missing keyID or malformed signature", i)
|
|
}
|
|
}
|
|
|
|
if a.State != nil {
|
|
if err := a.State.staticValidateCheckpoint(); err != nil {
|
|
return fmt.Errorf("checkpoint state: %v", err)
|
|
}
|
|
}
|
|
|
|
switch a.MessageKind {
|
|
case AUMAddKey:
|
|
if a.Key == nil {
|
|
return errors.New("AddKey AUMs must contain a key")
|
|
}
|
|
if a.KeyID != nil || a.State != nil || a.Votes != nil || a.Meta != nil {
|
|
return errors.New("AddKey AUMs may only specify a Key")
|
|
}
|
|
case AUMRemoveKey:
|
|
if len(a.KeyID) == 0 {
|
|
return errors.New("RemoveKey AUMs must specify a key ID")
|
|
}
|
|
if a.Key != nil || a.State != nil || a.Votes != nil || a.Meta != nil {
|
|
return errors.New("RemoveKey AUMs may only specify a KeyID")
|
|
}
|
|
case AUMUpdateKey:
|
|
if len(a.KeyID) == 0 {
|
|
return errors.New("UpdateKey AUMs must specify a key ID")
|
|
}
|
|
if a.Meta == nil && a.Votes == nil {
|
|
return errors.New("UpdateKey AUMs must contain an update to votes or key metadata")
|
|
}
|
|
if a.Key != nil || a.State != nil {
|
|
return errors.New("UpdateKey AUMs may only specify KeyID, Votes, and Meta")
|
|
}
|
|
case AUMCheckpoint:
|
|
if a.State == nil {
|
|
return errors.New("Checkpoint AUMs must specify the state")
|
|
}
|
|
if a.KeyID != nil || a.Key != nil || a.Votes != nil || a.Meta != nil {
|
|
return errors.New("Checkpoint AUMs may only specify State")
|
|
}
|
|
|
|
case AUMNoOp:
|
|
default:
|
|
// TODO(tom): Ignore unknown AUMs for GA.
|
|
return fmt.Errorf("unknown AUM kind: %v", a.MessageKind)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Serialize returns the given AUM in a serialized format.
|
|
//
|
|
// We would implement encoding.BinaryMarshaler, except that would
|
|
// unfortunately get called by the cbor marshaller resulting in infinite
|
|
// recursion.
|
|
func (a *AUM) Serialize() tkatype.MarshaledAUM {
|
|
// Why CBOR and not something like JSON?
|
|
//
|
|
// The main function of an AUM is to carry signed data. Signatures are
|
|
// over digests, so the serialized representation must be deterministic.
|
|
// Further, experience with other attempts (JWS/JWT,SAML,X509 etc) has
|
|
// taught us that even subtle behaviors such as how you handle invalid
|
|
// or unrecognized fields + any invariants in subsequent re-serialization
|
|
// can easily lead to security-relevant logic bugs. Its certainly possible
|
|
// to invent a workable scheme by massaging a JSON parsing library, though
|
|
// profoundly unwise.
|
|
//
|
|
// CBOR is one of the few encoding schemes that are appropriate for use
|
|
// with signatures and has security-conscious parsing + serialization
|
|
// rules baked into the spec. We use the CTAP2 mode, which is well
|
|
// understood + widely-implemented, and already proven for use in signing
|
|
// assertions through its use by FIDO2 devices.
|
|
out := bytes.NewBuffer(make([]byte, 0, 128))
|
|
encoder, err := cbor.CTAP2EncOptions().EncMode()
|
|
if err != nil {
|
|
// Deterministic validation of encoding options, should
|
|
// never fail.
|
|
panic(err)
|
|
}
|
|
if err := encoder.NewEncoder(out).Encode(a); err != nil {
|
|
// Writing to a bytes.Buffer should never fail.
|
|
panic(err)
|
|
}
|
|
return out.Bytes()
|
|
}
|
|
|
|
// Unserialize decodes bytes representing a marshaled AUM.
|
|
//
|
|
// We would implement encoding.BinaryUnmarshaler, except that would
|
|
// unfortunately get called by the cbor unmarshaller resulting in infinite
|
|
// recursion.
|
|
func (a *AUM) Unserialize(data []byte) error {
|
|
dec, _ := cborDecOpts.DecMode()
|
|
return dec.Unmarshal(data, a)
|
|
}
|
|
|
|
// Hash returns a cryptographic digest of all AUM contents.
|
|
func (a *AUM) Hash() AUMHash {
|
|
return blake2s.Sum256(a.Serialize())
|
|
}
|
|
|
|
// SigHash returns the cryptographic digest which a signature
|
|
// is over.
|
|
//
|
|
// This is identical to Hash() except the Signatures are not
|
|
// serialized. Without this, the hash used for signatures
|
|
// would be circularly dependent on the signatures.
|
|
func (a AUM) SigHash() tkatype.AUMSigHash {
|
|
dupe := a
|
|
dupe.Signatures = nil
|
|
return blake2s.Sum256(dupe.Serialize())
|
|
}
|
|
|
|
// Parent returns the parent's AUM hash and true, or a
|
|
// zero value and false if there was no parent.
|
|
func (a *AUM) Parent() (h AUMHash, ok bool) {
|
|
if len(a.PrevAUMHash) > 0 {
|
|
copy(h[:], a.PrevAUMHash)
|
|
return h, true
|
|
}
|
|
return h, false
|
|
}
|
|
|
|
func (a *AUM) sign25519(priv ed25519.PrivateKey) {
|
|
key := Key{Kind: Key25519, Public: priv.Public().(ed25519.PublicKey)}
|
|
sigHash := a.SigHash()
|
|
|
|
a.Signatures = append(a.Signatures, tkatype.Signature{
|
|
KeyID: key.ID(),
|
|
Signature: ed25519.Sign(priv, sigHash[:]),
|
|
})
|
|
}
|
|
|
|
// Weight computes the 'signature weight' of the AUM
|
|
// based on keys in the state machine. The caller must
|
|
// ensure that all signatures are valid.
|
|
//
|
|
// More formally: W = Sum(key.votes)
|
|
//
|
|
// AUMs with a higher weight than their siblings
|
|
// are preferred when resolving forks in the AUM chain.
|
|
func (a *AUM) Weight(state State) uint {
|
|
var weight uint
|
|
|
|
// Track the keys that have already been used, so two
|
|
// signatures with the same key do not result in 2x
|
|
// the weight.
|
|
//
|
|
// Despite the wire encoding being []byte, all KeyIDs are
|
|
// 32 bytes. As such, we use that as the key for the map,
|
|
// because map keys cannot be slices.
|
|
seenKeys := make(map[[32]byte]struct{}, 6)
|
|
for _, sig := range a.Signatures {
|
|
if len(sig.KeyID) != 32 {
|
|
panic("unexpected: keyIDs are 32 bytes")
|
|
}
|
|
|
|
var keyID [32]byte
|
|
copy(keyID[:], sig.KeyID)
|
|
|
|
key, err := state.GetKey(sig.KeyID)
|
|
if err != nil {
|
|
if err == ErrNoSuchKey {
|
|
// Signatures with an unknown key do not contribute
|
|
// to the weight.
|
|
continue
|
|
}
|
|
panic(err)
|
|
}
|
|
if _, seen := seenKeys[keyID]; seen {
|
|
continue
|
|
}
|
|
|
|
weight += key.Votes
|
|
seenKeys[keyID] = struct{}{}
|
|
}
|
|
|
|
return weight
|
|
}
|