github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/teams/seitan.go (about)

     1  package teams
     2  
     3  import (
     4  	"fmt"
     5  	"regexp"
     6  	"strings"
     7  
     8  	"crypto/hmac"
     9  	"crypto/rand"
    10  	"crypto/sha512"
    11  	"encoding/base64"
    12  	"encoding/hex"
    13  	"errors"
    14  
    15  	"golang.org/x/crypto/nacl/secretbox"
    16  	"golang.org/x/crypto/scrypt"
    17  	"golang.org/x/net/context"
    18  
    19  	libkb "github.com/keybase/client/go/libkb"
    20  	msgpack "github.com/keybase/client/go/msgpack"
    21  	keybase1 "github.com/keybase/client/go/protocol/keybase1"
    22  )
    23  
    24  // This is expected seitan token length, the secret "Invite Key" that
    25  // is generated on one client and distributed to another via face-to-
    26  // face meeting, use of a trusted courier etc.
    27  //
    28  // Seitan tokens have a '+' as the fifth character. We use this
    29  // to distinguish from email invite tokens (and team names).
    30  // See `IsSeitany`
    31  const SeitanEncodedIKeyLength = 18
    32  const seitanEncodedIKeyPlusOffset = 5
    33  
    34  // Key-Base 30 encoding. lower case letters except "ilot", and digits except for '0' and '1'.
    35  // See TestSeitanParams for a test to make sure these two parameters match up.
    36  const KBase30EncodeStd = "abcdefghjkmnpqrsuvwxyz23456789"
    37  const base30BitMask = byte(0x1f)
    38  
    39  type SeitanVersion uint
    40  
    41  const (
    42  	SeitanVersion1          SeitanVersion = 1
    43  	SeitanVersion2          SeitanVersion = 2
    44  	SeitanVersionInvitelink SeitanVersion = 3
    45  )
    46  
    47  // "Invite Key"
    48  type SeitanIKey string
    49  
    50  // "Seitan Packed Encrypted Key" All following 3 structs should be considered one.
    51  // When any changes, version has to be bumped up.
    52  type SeitanPKey struct {
    53  	_struct              bool `codec:",toarray"` //nolint
    54  	Version              SeitanVersion
    55  	TeamKeyGeneration    keybase1.PerTeamKeyGeneration
    56  	RandomNonce          keybase1.BoxNonce
    57  	EncryptedKeyAndLabel []byte // keybase1.SeitanKeyAndLabel MsgPacked and encrypted
    58  }
    59  
    60  func generateIKey(length int, plusOffset int) (str string, err error) {
    61  
    62  	alphabet := []byte(KBase30EncodeStd)
    63  	randEncodingByte := func() (byte, error) {
    64  		for {
    65  			var b [1]byte
    66  			_, err := rand.Read(b[:])
    67  			if err != nil {
    68  				return byte(0), err
    69  			}
    70  			i := int(b[0] & base30BitMask)
    71  			if i < len(alphabet) {
    72  				return alphabet[i], nil
    73  			}
    74  		}
    75  	}
    76  
    77  	var buf []byte
    78  	for i := 0; i < length; i++ {
    79  		if i == plusOffset {
    80  			buf = append(buf, '+')
    81  		} else {
    82  			b, err := randEncodingByte()
    83  			if err != nil {
    84  				return "", err
    85  			}
    86  			buf = append(buf, b)
    87  		}
    88  	}
    89  	return string(buf), nil
    90  }
    91  
    92  func GenerateIKey() (ikey SeitanIKey, err error) {
    93  	str, err := generateIKey(SeitanEncodedIKeyLength, seitanEncodedIKeyPlusOffset)
    94  	if err != nil {
    95  		return ikey, err
    96  	}
    97  	return SeitanIKey(str), err
    98  }
    99  
   100  var tokenPasteRegexp = regexp.MustCompile(`token\: [a-z0-9+]{16,28}`)
   101  
   102  // Returns the string that might be the token, and whether the content looked like a token paste.
   103  func ParseSeitanTokenFromPaste(token string) (parsed string, isSeitany bool) {
   104  	// If the person pasted the whole seitan SMS message in, then let's parse out the token
   105  	if strings.Contains(token, "token: ") {
   106  		m := tokenPasteRegexp.FindStringSubmatch(token)
   107  		if len(m) == 1 {
   108  			return strings.Split(m[0], " ")[1], true
   109  		}
   110  		return token, true
   111  	}
   112  	if groups := invitelinkIKeyRxx.FindStringSubmatch(token); groups != nil {
   113  		return groups[len(groups)-1], true
   114  	}
   115  	if IsSeitany(token) {
   116  		return token, true
   117  	}
   118  	return token, false
   119  }
   120  
   121  // ParseIKeyFromString safely creates SeitanIKey value from
   122  // plaintext string. Only format is checked - any 18-character token
   123  // with '+' character at position 5 can be "Invite Key". Alphabet is
   124  // not checked, as it is only a hint for token generation and it can
   125  // change over time, but we assume that token length stays the same.
   126  func ParseIKeyFromString(token string) (ikey SeitanIKey, err error) {
   127  	if len(token) != SeitanEncodedIKeyLength {
   128  		return ikey, fmt.Errorf("invalid token length: expected %d characters, got %d", SeitanEncodedIKeyLength, len(token))
   129  	}
   130  	if token[seitanEncodedIKeyPlusOffset] != '+' {
   131  		return ikey, fmt.Errorf("invalid token format: expected %dth character to be '+'", seitanEncodedIKeyPlusOffset+1)
   132  	}
   133  
   134  	return SeitanIKey(strings.ToLower(token)), nil
   135  }
   136  
   137  func (ikey SeitanIKey) String() string {
   138  	return strings.ToLower(string(ikey))
   139  }
   140  
   141  const (
   142  	SeitanScryptCost   = 1 << 10
   143  	SeitanScryptR      = 8
   144  	SeitanScryptP      = 1
   145  	SeitanScryptKeylen = 32
   146  )
   147  
   148  // "Stretched Invite Key"
   149  type SeitanSIKey [SeitanScryptKeylen]byte
   150  
   151  func generateSIKey(s string) (buf []byte, err error) {
   152  	buf, err = scrypt.Key([]byte(s), nil, SeitanScryptCost, SeitanScryptR, SeitanScryptP, SeitanScryptKeylen)
   153  	return buf, err
   154  }
   155  
   156  func (ikey SeitanIKey) GenerateSIKey() (sikey SeitanSIKey, err error) {
   157  	buf, err := generateSIKey(ikey.String())
   158  	if err != nil {
   159  		return sikey, err
   160  	}
   161  	copy(sikey[:], buf)
   162  	return sikey, nil
   163  }
   164  
   165  func generateTeamInviteIDRaw(secretKey []byte, payload []byte) ([]byte, error) {
   166  	mac := hmac.New(sha512.New, secretKey)
   167  	if _, err := mac.Write(payload); err != nil {
   168  		return nil, err
   169  	}
   170  	out := mac.Sum(nil)
   171  	out = out[0:15]
   172  	out = append(out, libkb.InviteIDTag)
   173  	return out, nil
   174  }
   175  
   176  func generateTeamInviteID(secretKey []byte, payload []byte) (id SCTeamInviteID, err error) {
   177  	out, err := generateTeamInviteIDRaw(secretKey, payload)
   178  	if err != nil {
   179  		return id, err
   180  	}
   181  	id = SCTeamInviteID(hex.EncodeToString(out))
   182  	return id, nil
   183  }
   184  
   185  func generateShortTeamInviteID(secretKey []byte, payload []byte) (id SCTeamInviteIDShort, err error) {
   186  	out, err := generateTeamInviteIDRaw(secretKey, payload)
   187  	if err != nil {
   188  		return id, err
   189  	}
   190  	id = SCTeamInviteIDShort(libkb.Base30.EncodeToString(out))
   191  	return id, nil
   192  }
   193  
   194  func (sikey SeitanSIKey) GenerateTeamInviteID() (id SCTeamInviteID, err error) {
   195  	type InviteStagePayload struct {
   196  		Stage string `codec:"stage" json:"stage"`
   197  	}
   198  
   199  	payload, err := msgpack.Encode(InviteStagePayload{Stage: "invite_id"})
   200  	if err != nil {
   201  		return id, err
   202  	}
   203  	return generateTeamInviteID(sikey[:], payload)
   204  }
   205  
   206  func packAndEncryptKeyWithSecretKey(secretKey keybase1.Bytes32, gen keybase1.PerTeamKeyGeneration, nonce keybase1.BoxNonce, packedKeyAndLabel []byte, version SeitanVersion) (pkey SeitanPKey, encoded string, err error) {
   207  	var encKey [libkb.NaclSecretBoxKeySize]byte = secretKey
   208  	var naclNonce [libkb.NaclDHNonceSize]byte = nonce
   209  	encryptedKeyAndLabel := secretbox.Seal(nil, packedKeyAndLabel, &naclNonce, &encKey)
   210  
   211  	pkey = SeitanPKey{
   212  		Version:              version,
   213  		TeamKeyGeneration:    gen,
   214  		RandomNonce:          nonce,
   215  		EncryptedKeyAndLabel: encryptedKeyAndLabel,
   216  	}
   217  
   218  	packed, err := msgpack.Encode(pkey)
   219  	if err != nil {
   220  		return pkey, encoded, err
   221  	}
   222  
   223  	encoded = base64.StdEncoding.EncodeToString(packed)
   224  	return pkey, encoded, nil
   225  }
   226  
   227  func (ikey SeitanIKey) generatePackedEncryptedKeyWithSecretKey(secretKey keybase1.Bytes32, gen keybase1.PerTeamKeyGeneration, nonce keybase1.BoxNonce, label keybase1.SeitanKeyLabel) (pkey SeitanPKey, encoded string, err error) {
   228  	var keyAndLabel keybase1.SeitanKeyAndLabelVersion1
   229  	keyAndLabel.I = keybase1.SeitanIKey(ikey)
   230  	keyAndLabel.L = label
   231  
   232  	packedKeyAndLabel, err := msgpack.Encode(keybase1.NewSeitanKeyAndLabelWithV1(keyAndLabel))
   233  	if err != nil {
   234  		return pkey, encoded, err
   235  	}
   236  	return packAndEncryptKeyWithSecretKey(secretKey, gen, nonce, packedKeyAndLabel, SeitanVersion1)
   237  }
   238  
   239  func (ikey SeitanIKey) GeneratePackedEncryptedKey(ctx context.Context, team *Team, label keybase1.SeitanKeyLabel) (pkey SeitanPKey, encoded string, err error) {
   240  	appKey, err := team.SeitanInviteTokenKeyLatest(ctx)
   241  	if err != nil {
   242  		return pkey, encoded, err
   243  	}
   244  
   245  	var nonce keybase1.BoxNonce
   246  	if _, err = rand.Read(nonce[:]); err != nil {
   247  		return pkey, encoded, err
   248  	}
   249  
   250  	return ikey.generatePackedEncryptedKeyWithSecretKey(appKey.Key, appKey.KeyGeneration, nonce, label)
   251  }
   252  
   253  func SeitanDecodePKey(base64Buffer string) (pkey SeitanPKey, err error) {
   254  	packed, err := base64.StdEncoding.DecodeString(base64Buffer)
   255  	if err != nil {
   256  		return pkey, err
   257  	}
   258  
   259  	err = msgpack.Decode(&pkey, packed)
   260  	return pkey, err
   261  }
   262  
   263  func (pkey SeitanPKey) decryptKeyAndLabelWithSecretKey(secretKey keybase1.Bytes32) (ret keybase1.SeitanKeyAndLabel, err error) {
   264  	var encKey [libkb.NaclSecretBoxKeySize]byte = secretKey
   265  	var naclNonce [libkb.NaclDHNonceSize]byte = pkey.RandomNonce
   266  	plain, ok := secretbox.Open(nil, pkey.EncryptedKeyAndLabel, &naclNonce, &encKey)
   267  	if !ok {
   268  		return ret, errors.New("failed to decrypt seitan plain")
   269  	}
   270  
   271  	err = msgpack.Decode(&ret, plain)
   272  	if err != nil {
   273  		return ret, err
   274  	}
   275  
   276  	return ret, nil
   277  }
   278  
   279  func (pkey SeitanPKey) DecryptKeyAndLabel(ctx context.Context, team *Team) (ret keybase1.SeitanKeyAndLabel, err error) {
   280  	appKey, err := team.SeitanInviteTokenKeyAtGeneration(ctx, pkey.TeamKeyGeneration)
   281  	if err != nil {
   282  		return ret, err
   283  	}
   284  
   285  	return pkey.decryptKeyAndLabelWithSecretKey(appKey.Key)
   286  }
   287  
   288  // "Acceptance Key"
   289  type SeitanAKey []byte
   290  
   291  func generateAcceptanceKey(akeyPayload []byte, sikey []byte) (akey SeitanAKey, encoded string, err error) {
   292  	mac := hmac.New(sha512.New, sikey)
   293  	_, err = mac.Write(akeyPayload)
   294  	if err != nil {
   295  		return akey, encoded, err
   296  	}
   297  
   298  	out := mac.Sum(nil)
   299  	akey = out[:32]
   300  	encoded = base64.StdEncoding.EncodeToString(akey)
   301  	return akey, encoded, nil
   302  }
   303  
   304  func (sikey SeitanSIKey) GenerateAcceptanceKey(uid keybase1.UID, eldestSeqno keybase1.Seqno, unixTime int64) (akey SeitanAKey, encoded string, err error) {
   305  	type AKeyPayload struct {
   306  		Stage       string         `codec:"stage" json:"stage"`
   307  		UID         keybase1.UID   `codec:"uid" json:"uid"`
   308  		EldestSeqno keybase1.Seqno `codec:"eldest_seqno" json:"eldest_seqno"`
   309  		CTime       int64          `codec:"ctime" json:"ctime"`
   310  	}
   311  
   312  	akeyPayload, err := msgpack.Encode(AKeyPayload{
   313  		Stage:       "accept",
   314  		UID:         uid,
   315  		EldestSeqno: eldestSeqno,
   316  		CTime:       unixTime,
   317  	})
   318  	if err != nil {
   319  		return akey, encoded, err
   320  	}
   321  	return generateAcceptanceKey(akeyPayload, sikey[:])
   322  }
   323  
   324  // IsSeitany is a very conservative check of whether a given string looks like
   325  // a Seitan token. We want to err on the side of considering strings Seitan
   326  // tokens, since we don't mistakenly want to send botched Seitan tokens to the
   327  // server.
   328  func IsSeitany(s string) bool {
   329  	// use the minimum seitan offset value
   330  	return len(s) > seitanEncodedIKeyPlusOffset && strings.IndexByte(s, '+') > 1
   331  }
   332  
   333  // DeriveSeitanVersionFromToken returns possible seitan version based on the
   334  // token. Different seitan versions have '+' characters at different position
   335  // signifying version number. This function returning successfully does not mean
   336  // that token is correct, valid, seitan. But returning an error means that token
   337  // is definitely not a correct seitan token.
   338  func DeriveSeitanVersionFromToken(token string) (version SeitanVersion, err error) {
   339  	switch {
   340  	case !IsSeitany(token):
   341  		return 0, errors.New("Invalid token, not seitan-y")
   342  	case len(token) > seitanEncodedIKeyPlusOffset && token[seitanEncodedIKeyPlusOffset] == '+':
   343  		return SeitanVersion1, nil
   344  	case len(token) > seitanEncodedIKeyV2PlusOffset && token[seitanEncodedIKeyV2PlusOffset] == '+':
   345  		return SeitanVersion2, nil
   346  	case len(token) > seitanEncodedIKeyInvitelinkPlusOffset &&
   347  		token[seitanEncodedIKeyInvitelinkPlusOffset] == '+':
   348  
   349  		return SeitanVersionInvitelink, nil
   350  	default:
   351  		return 0, errors.New("Invalid token, invalid '+' position")
   352  	}
   353  }