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 }