/
request.go
339 lines (301 loc) · 13.1 KB
/
request.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
package login
import (
"bytes"
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
"strings"
"time"
)
// chain holds a chain with claims, each with their own headers, payloads and signatures. Each claim holds
// a public key used to verify other claims.
type chain []string
// request is the outer encapsulation of the request. It holds a chain and a ClientData object.
type request struct {
// Chain is the client certificate chain. It holds several claims that the server may verify in order to
// make sure that the client is logged into XBOX Live.
Chain chain `json:"chain"`
// RawToken holds the raw token that follows the JWT chain, holding the ClientData.
RawToken string `json:"-"`
}
func init() {
//noinspection SpellCheckingInspection
const mojangPublicKey = `MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAECRXueJeTDqNRRgJi/vlRufByu/2G0i2Ebt6YMar5QX/R0DIIyrJMcUpruK4QveTfJSTp3Shlq4Gk34cD/4GUWwkv0DVuzeuB+tXija7HBxii03NHDbPAD0AKnLr2wdAp`
data, _ := base64.StdEncoding.DecodeString(mojangPublicKey)
publicKey, _ := x509.ParsePKIXPublicKey(data)
mojangKey = publicKey.(*ecdsa.PublicKey)
}
// mojangKey holds the parsed Mojang ecdsa.PublicKey.
var mojangKey = new(ecdsa.PublicKey)
// AuthResult is returned by a call to Parse. It holds the ecdsa.PublicKey of the client and a bool that
// indicates if the player was logged in with XBOX Live.
type AuthResult struct {
PublicKey *ecdsa.PublicKey
XBOXLiveAuthenticated bool
}
// Parse parses and verifies the login request passed. The AuthResult returned holds the ecdsa.PublicKey that
// was parsed (which is used for encryption) and a bool specifying if the request was authenticated by XBOX
// Live.
// Parse returns IdentityData and ClientData, of which IdentityData cannot under any circumstance be edited by
// the client. Rather, it is obtained from an authentication endpoint. The ClientData can, however, be edited
// freely by the client.
func Parse(request []byte) (IdentityData, ClientData, AuthResult, error) {
var (
iData IdentityData
cData ClientData
res AuthResult
key = &ecdsa.PublicKey{}
)
req, err := parseLoginRequest(request)
if err != nil {
return iData, cData, res, fmt.Errorf("parse login request: %w", err)
}
tok, err := jwt.ParseSigned(req.Chain[0])
if err != nil {
return iData, cData, res, fmt.Errorf("parse token 0: %w", err)
}
// The first token holds the client's public key in the x5u (it's self signed).
//lint:ignore S1005 Double assignment is done explicitly to prevent panics.
raw, _ := tok.Headers[0].ExtraHeaders["x5u"]
if err := parseAsKey(raw, key); err != nil {
return iData, cData, res, fmt.Errorf("parse x5u: %w", err)
}
var identityClaims identityClaims
var authenticated bool
t, iss := time.Now(), "Mojang"
switch len(req.Chain) {
case 1:
// Player was not authenticated with XBOX Live, meaning the one token in here is self-signed.
if err := parseFullClaim(req.Chain[0], key, &identityClaims); err != nil {
return iData, cData, res, err
}
if err := identityClaims.Validate(jwt.Expected{Time: t}); err != nil {
return iData, cData, res, fmt.Errorf("validate token 0: %w", err)
}
case 3:
// Player was (or should be) authenticated with XBOX Live, meaning the chain is exactly 3 tokens
// long.
var c jwt.Claims
if err := parseFullClaim(req.Chain[0], key, &c); err != nil {
return iData, cData, res, fmt.Errorf("parse token 0: %w", err)
}
if err := c.Validate(jwt.Expected{Time: t}); err != nil {
return iData, cData, res, fmt.Errorf("validate token 0: %w", err)
}
authenticated = bytes.Equal(key.X.Bytes(), mojangKey.X.Bytes()) && bytes.Equal(key.Y.Bytes(), mojangKey.Y.Bytes())
if err := parseFullClaim(req.Chain[1], key, &c); err != nil {
return iData, cData, res, fmt.Errorf("parse token 1: %w", err)
}
if err := c.Validate(jwt.Expected{Time: t, Issuer: iss}); err != nil {
return iData, cData, res, fmt.Errorf("validate token 1: %w", err)
}
if err := parseFullClaim(req.Chain[2], key, &identityClaims); err != nil {
return iData, cData, res, fmt.Errorf("parse token 2: %w", err)
}
if err := identityClaims.Validate(jwt.Expected{Time: t, Issuer: iss}); err != nil {
return iData, cData, res, fmt.Errorf("validate token 2: %w", err)
}
if authenticated != (identityClaims.ExtraData.XUID != "") {
return iData, cData, res, fmt.Errorf("identity data must have an XUID when logged into XBOX Live only")
}
if authenticated != (identityClaims.ExtraData.TitleID != "") {
return iData, cData, res, fmt.Errorf("identity data must have a title ID when logged into XBOX Live only")
}
default:
return iData, cData, res, fmt.Errorf("unexpected login chain length %v", len(req.Chain))
}
if err := parseFullClaim(req.RawToken, key, &cData); err != nil {
return iData, cData, res, fmt.Errorf("parse client data: %w", err)
}
if strings.Count(cData.ServerAddress, ":") > 1 && cData.ServerAddress[0] != '[' {
// IPv6: We can't net.ResolveUDPAddr this directly, because Mojang does
// not always put [] around the IP if it isn't added by the player in
// the External Server adding screen. We'll have to do this manually:
ind := strings.LastIndex(cData.ServerAddress, ":")
cData.ServerAddress = "[" + cData.ServerAddress[:ind] + "]" + cData.ServerAddress[ind:]
}
if err := cData.Validate(); err != nil {
return iData, cData, res, fmt.Errorf("validate client data: %w", err)
}
return identityClaims.ExtraData, cData, AuthResult{PublicKey: key, XBOXLiveAuthenticated: authenticated}, nil
}
// parseLoginRequest parses the structure of a login request from the data passed and returns it.
func parseLoginRequest(requestData []byte) (*request, error) {
buf := bytes.NewBuffer(requestData)
chain, err := decodeChain(buf)
if err != nil {
return nil, err
}
if len(chain) < 1 {
return nil, fmt.Errorf("JWT chain must be at least 1 token long")
}
var rawLength int32
if err := binary.Read(buf, binary.LittleEndian, &rawLength); err != nil {
return nil, fmt.Errorf("error reading raw token length: %v", err)
}
return &request{Chain: chain, RawToken: string(buf.Next(int(rawLength)))}, nil
}
// parseFullClaim parses and verifies a full claim using the ecdsa.PublicKey passed. The key passed is updated
// if the claim holds an identityPublicKey field.
// The value v passed is decoded into when reading the claims.
func parseFullClaim(claim string, key *ecdsa.PublicKey, v any) error {
tok, err := jwt.ParseSigned(claim)
if err != nil {
return fmt.Errorf("error parsing signed token: %w", err)
}
var m map[string]any
if err := tok.Claims(key, v, &m); err != nil {
return fmt.Errorf("error verifying claims of token: %w", err)
}
newKey, present := m["identityPublicKey"]
if present {
if err := parseAsKey(newKey, key); err != nil {
return fmt.Errorf("error parsing identity public key: %w", err)
}
}
return nil
}
// parseAsKey parses the base64 encoded ecdsa.PublicKey held in k as a public key and sets it to the variable
// pub passed.
func parseAsKey(k any, pub *ecdsa.PublicKey) error {
kStr, _ := k.(string)
if err := ParsePublicKey(kStr, pub); err != nil {
return fmt.Errorf("error parsing public key: %w", err)
}
return nil
}
// Encode encodes a login request using the encoded login chain passed and the client data. The request's
// client data token is signed using the private key passed. It must be the same as the one used to get the
// login chain.
func Encode(loginChain string, data ClientData, key *ecdsa.PrivateKey) []byte {
// We first decode the login chain we actually got in a new request.
request := &request{}
_ = json.Unmarshal([]byte(loginChain), &request)
// We parse the header of the first claim it has in the chain, which will soon be the second claim.
keyData := MarshalPublicKey(&key.PublicKey)
tok, _ := jwt.ParseSigned(request.Chain[0])
//lint:ignore S1005 Double assignment is done explicitly to prevent panics.
x5uData, _ := tok.Headers[0].ExtraHeaders["x5u"]
x5u, _ := x5uData.(string)
claims := jwt.Claims{
Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour * 6)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Hour * 6)),
}
signer, _ := jose.NewSigner(jose.SigningKey{Key: key, Algorithm: jose.ES384}, &jose.SignerOptions{
ExtraHeaders: map[jose.HeaderKey]any{"x5u": keyData},
})
firstJWT, _ := jwt.Signed(signer).Claims(identityPublicKeyClaims{
Claims: claims,
IdentityPublicKey: x5u,
CertificateAuthority: true,
}).CompactSerialize()
// We add our own claim at the start of the chain.
request.Chain = append(chain{firstJWT}, request.Chain...)
// We create another token this time, which is signed the same as the claim we just inserted in the chain,
// just now it contains client data.
request.RawToken, _ = jwt.Signed(signer).Claims(data).CompactSerialize()
return encodeRequest(request)
}
// encodeRequest encodes the request passed to a byte slice which is suitable for setting to the Connection
// Request field in a Login packet.
func encodeRequest(req *request) []byte {
chainBytes, _ := json.Marshal(req)
buf := bytes.NewBuffer(nil)
_ = binary.Write(buf, binary.LittleEndian, int32(len(chainBytes)))
_, _ = buf.WriteString(string(chainBytes))
_ = binary.Write(buf, binary.LittleEndian, int32(len(req.RawToken)))
_, _ = buf.WriteString(req.RawToken)
return buf.Bytes()
}
// EncodeOffline creates a login request using the identity data and client data passed. The private key
// passed will be used to self sign the JWTs.
// Unlike Encode, EncodeOffline does not have a token signed by the Mojang key. It consists of only one JWT
// which holds the identity data of the player.
func EncodeOffline(identityData IdentityData, data ClientData, key *ecdsa.PrivateKey) []byte {
keyData := MarshalPublicKey(&key.PublicKey)
claims := jwt.Claims{
Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour * 6)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Hour * 6)),
}
signer, _ := jose.NewSigner(jose.SigningKey{Key: key, Algorithm: jose.ES384}, &jose.SignerOptions{
ExtraHeaders: map[jose.HeaderKey]any{"x5u": keyData},
})
firstJWT, _ := jwt.Signed(signer).Claims(identityClaims{
Claims: claims,
ExtraData: identityData,
IdentityPublicKey: keyData,
}).CompactSerialize()
request := &request{Chain: chain{firstJWT}}
// We create another token this time, which is signed the same as the claim we just inserted in the chain,
// just now it contains client data.
request.RawToken, _ = jwt.Signed(signer).Claims(data).CompactSerialize()
return encodeRequest(request)
}
// decodeChain reads a certificate chain from the buffer passed and returns each claim found in the chain.
func decodeChain(buf *bytes.Buffer) (chain, error) {
var chainLength int32
if err := binary.Read(buf, binary.LittleEndian, &chainLength); err != nil {
return nil, fmt.Errorf("error reading chain length: %v", err)
}
chainData := buf.Next(int(chainLength))
request := &request{}
if err := json.Unmarshal(chainData, request); err != nil {
return nil, fmt.Errorf("error decoding request chain JSON: %v", err)
}
// First check if the chain actually has any elements in it.
if len(request.Chain) == 0 {
return nil, fmt.Errorf("connection request had no claims in the chain")
}
return request.Chain, nil
}
// identityClaims holds the claims for the last token in the chain, which contains the IdentityData of the
// player.
type identityClaims struct {
jwt.Claims
// ExtraData holds the extra data of this claim, which is the IdentityData of the player.
ExtraData IdentityData `json:"extraData"`
IdentityPublicKey string `json:"identityPublicKey"`
}
// Validate validates the identity claims held by the struct and returns an error if any illegal data was
// encountered.
func (c identityClaims) Validate(e jwt.Expected) error {
if err := c.Claims.Validate(e); err != nil {
return err
}
return c.ExtraData.Validate()
}
// identityPublicKeyClaims holds the claims for a JWT that holds an identity public key.
type identityPublicKeyClaims struct {
jwt.Claims
// IdentityPublicKey holds a serialised ecdsa.PublicKey used in the next JWT in the chain.
IdentityPublicKey string `json:"identityPublicKey"`
CertificateAuthority bool `json:"certificateAuthority,omitempty"`
}
// ParsePublicKey parses an ecdsa.PublicKey from the base64 encoded public key data passed and sets it to a
// pointer. If parsing failed or if the public key was not of the type ECDSA, an error is returned.
func ParsePublicKey(b64Data string, key *ecdsa.PublicKey) error {
data, err := base64.StdEncoding.DecodeString(b64Data)
if err != nil {
return fmt.Errorf("error base64 decoding public key data: %v", err)
}
publicKey, err := x509.ParsePKIXPublicKey(data)
if err != nil {
return fmt.Errorf("error parsing public key: %v", err)
}
ecdsaKey, ok := publicKey.(*ecdsa.PublicKey)
if !ok {
return fmt.Errorf("expected ECDSA public key, but got %v", key)
}
*key = *ecdsaKey
return nil
}
// MarshalPublicKey marshals an ecdsa.PublicKey to a base64 encoded binary representation.
func MarshalPublicKey(key *ecdsa.PublicKey) string {
data, _ := x509.MarshalPKIXPublicKey(key)
return base64.StdEncoding.EncodeToString(data)
}