1
|
#!/usr/bin/env python3
|
2
|
|
3
|
from cryptography.hazmat.primitives.kdf import hkdf
|
4
|
from cryptography.hazmat.backends import openssl
|
5
|
from cryptography.hazmat.primitives import hashes, hmac
|
6
|
import base64, json, librecaptcha, random, requests, sys, time
|
7
|
|
8
|
s, offset = requests.Session(), 0
|
9
|
s.headers.update({'user-agent': 'Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0'})
|
10
|
|
11
|
|
12
|
|
13
|
def get(varname):
|
14
|
if varname not in options:
|
15
|
options[varname] = input('Please enter ' + varname.replace('_', ' ') + ': ')
|
16
|
|
17
|
if varname not in options:
|
18
|
raise Exception('Could not obtain a value for ' + varname + '.')
|
19
|
|
20
|
return options[varname]
|
21
|
|
22
|
|
23
|
def hawk_header(payload, *request): # request = (method, path, host, port)~ all str
|
24
|
hasher, hmacer = hashes.Hash(hashes.SHA256(), openssl.backend), hmac.HMAC(key, hashes.SHA256(), openssl.backend)
|
25
|
hasher.update(('hawk.1.payload\napplication/json\n' + payload + '\n').encode('utf-8'))
|
26
|
|
27
|
payload_hash, time_stamp = base64.b64encode(hasher.finalize()).decode('utf-8'), str(int(time.time() + offset))
|
28
|
nonce = ''.join([ random.choice('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') for i in range(6) ])
|
29
|
|
30
|
hmacer.update(('hawk.1.header\n' + time_stamp + '\n' + nonce + '\n' + '\n'.join(request) + '\n' + payload_hash + '\n\n').encode('utf-8'))
|
31
|
mac = base64.b64encode(hmacer.finalize()).decode('utf-8')
|
32
|
return 'Hawk id="' + key_id + '", ts="' + time_stamp + '", nonce="' + nonce + '", hash="' + payload_hash + '", mac="' + mac + '"'
|
33
|
|
34
|
|
35
|
def jwt_header():
|
36
|
# "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" below is the base-64 encoding of the header {"alg":"HS256","typ":"JWT"}
|
37
|
time_stamp, hmacer = int(time.time() + offset), hmac.HMAC(jwtsec, hashes.SHA256(), openssl.backend)
|
38
|
payload = '{"iss":"' + jwtkey + '","iat":' + str(time_stamp) + ',"jti":"'
|
39
|
payload += ''.join([ random.choice('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') for i in range(9) ])
|
40
|
payload += '","exp":' + str(time_stamp + 60) + '}'
|
41
|
body = b'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + base64.b64encode(payload.encode('utf-8'), b'-_').rstrip(b'=')
|
42
|
hmacer.update(body)
|
43
|
body += b'.' + base64.b64encode(hmacer.finalize(), b'-_').rstrip(b'=')
|
44
|
return 'JWT ' + body.decode('utf-8')
|
45
|
|
46
|
def req(method, *args, **kwargs):
|
47
|
r = getattr(s, method)(*args, **kwargs)
|
48
|
if r.status_code < 200 or r.status_code >= 300:
|
49
|
raise Exception(str(r.status_code) + ':\n' + r.text)
|
50
|
|
51
|
return r
|
52
|
|
53
|
|
54
|
|
55
|
aliases = {
|
56
|
'name': 'display_name',
|
57
|
'display-name': 'display_name',
|
58
|
'n': 'display_name',
|
59
|
'p': 'stretched_password',
|
60
|
'e': 'e-mail',
|
61
|
'email': 'e-mail',
|
62
|
'stretched-password': 'stretched_password',
|
63
|
'password': 'stretched_password',
|
64
|
'i': 'info',
|
65
|
'k': 'keys',
|
66
|
'key': 'keys',
|
67
|
'c': 'create',
|
68
|
'd': 'delete',
|
69
|
'del': 'delete',
|
70
|
'v': 'verify',
|
71
|
'u': 'unblock',
|
72
|
'j': 'json_key_pair',
|
73
|
'json-key-pair': 'json_key_pair',
|
74
|
'json-keys': 'json_key_pair',
|
75
|
'key-pair': 'json_key_pair',
|
76
|
'keypair': 'json_key_pair',
|
77
|
'h': 'help'
|
78
|
}
|
79
|
|
80
|
long_args = {
|
81
|
'display_name': True,
|
82
|
'stretched_password': True,
|
83
|
'info': True,
|
84
|
'keys': False,
|
85
|
'create': False,
|
86
|
'delete': False,
|
87
|
'verify': True,
|
88
|
'unblock': False,
|
89
|
'json_key_pair': True,
|
90
|
'e-mail': True,
|
91
|
'help': False
|
92
|
}
|
93
|
|
94
|
|
95
|
|
96
|
|
97
|
options, current_arg = {}, None
|
98
|
for arg in sys.argv[1:]:
|
99
|
if current_arg:
|
100
|
options[current_arg], current_arg = arg, None
|
101
|
|
102
|
elif not arg.startswith('-') and len(arg) > 1:
|
103
|
raise Exception('Positional argument found: positional arguments are not allowed.')
|
104
|
|
105
|
else:
|
106
|
if arg.startswith('--'):
|
107
|
arg = arg[2:].lower()
|
108
|
else:
|
109
|
args = list(arg[1:])
|
110
|
arg = args.pop()
|
111
|
for arg2 in args:
|
112
|
if arg2 not in aliases:
|
113
|
raise Exception('Invalid short argument -' + arg2 + '.')
|
114
|
|
115
|
long_arg = aliases[arg2]
|
116
|
if long_args[long_arg]:
|
117
|
raise Exception('Short argument -' + arg2 + ' must take an argument.')
|
118
|
|
119
|
options[long_arg] = True
|
120
|
|
121
|
if arg in aliases:
|
122
|
arg = aliases[arg]
|
123
|
|
124
|
elif arg not in long_args:
|
125
|
raise Exception('Invalid argument -(-)' + arg + '.')
|
126
|
|
127
|
if long_args[arg]:
|
128
|
current_arg = arg
|
129
|
else:
|
130
|
options[arg] = True
|
131
|
|
132
|
if current_arg:
|
133
|
raise Exception('Terminated with option name.')
|
134
|
|
135
|
|
136
|
if 'help' in options:
|
137
|
print('''Usage:', sys.argv[0], '[OPTIONS]')
|
138
|
Available options:
|
139
|
-c, --create
|
140
|
Generate an account or (with -k) a keypair
|
141
|
-d, --delete
|
142
|
Delete an account (use with -k unsupported)
|
143
|
-k, --keys
|
144
|
Perform an action on API keypair; if not specified, print the current keypair
|
145
|
-h, --help
|
146
|
Print this help text
|
147
|
|
148
|
-n, --name [DISPLAY NAME]
|
149
|
Set the display to use when creating an account
|
150
|
-e, --email, --e-mail [E-MAIL ADDRESS]
|
151
|
Account e-mail address
|
152
|
-p, --stretched-password, --stretched_password [STRING]
|
153
|
Stretched account password (a 64-character hex string)
|
154
|
-u, --unblock
|
155
|
Unblock the account before logging in
|
156
|
''')
|
157
|
sys.exit(0)
|
158
|
|
159
|
|
160
|
elif 'info' in options:
|
161
|
with open(options['info'], mode='r') as f:
|
162
|
line = f.readline()
|
163
|
while line:
|
164
|
if ':' in line:
|
165
|
key, value = line.split(':', maxsplit=1)
|
166
|
key, value = key.lower(), value[:-1]
|
167
|
if key in aliases:
|
168
|
key = aliases[key]
|
169
|
|
170
|
if key not in options and long_args.get(key):
|
171
|
long_args[key] = value
|
172
|
line = f.readline()
|
173
|
|
174
|
|
175
|
|
176
|
|
177
|
if 'delete' in options or 'create' in options or 'keys' in options:
|
178
|
email, pwd = get('e-mail'), get('stretched_password')
|
179
|
if '@' not in email:
|
180
|
raise Exception('Invalid e-mail address.')
|
181
|
|
182
|
if len(pwd) != 64 or pwd.lstrip('1234567890abcdef'):
|
183
|
raise Exception('Invalid stretched password: should be a 64-character hex string.')
|
184
|
|
185
|
unblock = None
|
186
|
if 'create' in options and 'keys' not in options:
|
187
|
endpoint, verify = 'create', 'email-otp'
|
188
|
else:
|
189
|
endpoint, verify = 'login', None
|
190
|
if 'verify' in options:
|
191
|
verify = options['verify']
|
192
|
|
193
|
elif 'unblock' in options:
|
194
|
print('[Making unblock request]')
|
195
|
req('post', 'https://api.accounts.firefox.com/v1/account/login/send_unblock_code',
|
196
|
json={'email': email, 'metricsContent': {}})
|
197
|
|
198
|
print('A code should have been sent to your e-mail address.')
|
199
|
unblock = input('Please enter code: ')
|
200
|
if len(unblock) != 8 or not unblock.isupper() or not unblock.isalnum():
|
201
|
raise Exception('Invalid unblock code: should be 8 characters, either uppercase letters or digits')
|
202
|
|
203
|
data = {'email': email, 'authPW': pwd, 'service': 'a4907de5fa9d78fc', 'metricsContext': {}}
|
204
|
if verify:
|
205
|
data['verificationMethod'] = verify
|
206
|
|
207
|
if unblock:
|
208
|
data['unblockCode'] = unblock
|
209
|
|
210
|
print('[Signing in]')
|
211
|
data = req('post', 'https://api.accounts.firefox.com/v1/account/' + endpoint, json=data).json()
|
212
|
uid, offset = data['uid'], data['authAt'] - time.time()
|
213
|
|
214
|
session_token = int(data['sessionToken'], 16)
|
215
|
session_token = session_token.to_bytes((session_token.bit_length() + 7) // 8, 'big')
|
216
|
key_block = hkdf.HKDF(hashes.SHA256(), 32*3*8, b'', b'identity.mozilla.com/picl/v1/sessionToken', openssl.backend).derive(session_token)
|
217
|
# algorithm, length, salt, info, backend
|
218
|
|
219
|
key_id, key = key_block[:32].hex(), key_block[32:64]
|
220
|
|
221
|
# Handle verification
|
222
|
if verify == 'email-otp':
|
223
|
print('A six-digit verification code should have been sent to your e-mail address.')
|
224
|
code = input('Please enter verification code: ')
|
225
|
if not (len(code) == 6 and code.isdigit()):
|
226
|
raise Exception('Invalid verification code: codes should be 6 digits.')
|
227
|
|
228
|
data = json.dumps({'code': code, 'service': 'a4907de5fa9d78fc', 'scopes': ['profile','openid'], 'newsletters': []})
|
229
|
auth_header = hawk_header(data, 'POST', '/v1/session/verify_code', 'api.accounts.firefox.com', '443')
|
230
|
|
231
|
print('[Seeking verification]')
|
232
|
req('post', 'https://api.accounts.firefox.com/v1/session/verify_code',
|
233
|
headers={'authorization': auth_header, 'content-type': 'application/json'}, data=data)
|
234
|
|
235
|
|
236
|
# Get OAuth
|
237
|
print('[Obtaining OAuth permissions- 3 requests to be made]')
|
238
|
r = req('get', 'https://addons.mozilla.org/api/v4/accounts/login/start/?config=amo&to=%2Fen-US%2Ffirefox%2F')
|
239
|
state = r.url.split('&state=', maxsplit=1)[1].split('&', maxsplit=1)[0].replace('%3A', ':')
|
240
|
|
241
|
data = json.dumps({'client_id': 'a4907de5fa9d78fc', 'scope': 'profile openid', 'state': state})
|
242
|
auth_header = hawk_header(data, 'POST', '/v1/oauth/authorization', 'api.accounts.firefox.com', '443')
|
243
|
r = req('post', 'https://api.accounts.firefox.com/v1/oauth/authorization',
|
244
|
headers={'authorization': auth_header, 'content-type': 'application/json'}, data=data)
|
245
|
|
246
|
req('get', r.json()['redirect'])
|
247
|
|
248
|
|
249
|
if 'create' in options:
|
250
|
dbase = {'action': 'generate'}
|
251
|
r = req('get', 'https://addons.mozilla.org/en-US/developers/addon/api/key/')
|
252
|
csrf = r.text.replace("'", '"').split('<meta name="csrf" content="', maxsplit=1)[1].split('"', maxsplit=1)[0]
|
253
|
if r.url == 'https://addons.mozilla.org/en-US/developers/addon/agreement/':
|
254
|
# Part of the initial sign-up process
|
255
|
print("LEGAL NOTICE:\nBy continuing, you accept Mozilla's:\n")
|
256
|
print("-\tdistribution agreement <https://extensionworkshop.com/documentation/publish/firefox-add-on-distribution-agreement/>")
|
257
|
print("-\treview policy <https://extensionworkshop.com/documentation/publish/add-on-policies/>")
|
258
|
|
259
|
display_name = get('display_name')
|
260
|
token = librecaptcha.get_token('6LcsBhYTAAAAAKpVTZ16Lh7nmTxVRKoKSr8e3vsV', 'https://addons.mozilla.org/',
|
261
|
'Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0')
|
262
|
|
263
|
data = {'distribution_agreement': 'on', 'review_policy': 'on', 'display_name': display_name, \
|
264
|
'g-recaptcha-response': token, 'csrfmiddlewaretoken': csrf}
|
265
|
|
266
|
r = req('post', 'https://addons.mozilla.org/en-US/developers/addon/agreement/', data=data,
|
267
|
headers={'referer': 'https://addons.mozilla.org/en-US/developers/addon/agreement/'})
|
268
|
|
269
|
csrf = r.text.split('<meta name="csrf" content="', maxsplit=1)[1].split('"', maxsplit=1)[0]
|
270
|
|
271
|
dbase['csrfmiddlewaretoken'] = csrf
|
272
|
req('post', 'https://addons.mozilla.org/en-US/developers/addon/api/key/',
|
273
|
headers={'referer': 'https://addons.mozilla.org/en-US/developers/addon/api/key/'}, data=dbase)
|
274
|
|
275
|
print('If you have not created a keypair before, a link should have been sent to your e-mail address.')
|
276
|
token = input('Please enter link if applicable, nothing otherwise: ')
|
277
|
if token:
|
278
|
if '://' not in token:
|
279
|
raise Exception('Invalid link.')
|
280
|
|
281
|
token = token.split('?token=', maxsplit=1)[1].split('&', maxsplit=1)[0]
|
282
|
dbase = {'confirmation_token': token, 'action': 'generate'}
|
283
|
r = req('get', 'https://addons.mozilla.org/en-US/developers/addon/api/key/?token=' + token)
|
284
|
dbase['csrfmiddlewaretoken'] = r.text.replace("'", '"').split('<meta name="csrf" content="', maxsplit=1)[1].split('"', maxsplit=1)[0]
|
285
|
|
286
|
r = req('post', 'https://addons.mozilla.org/en-US/developers/addon/api/key/?token=' + token,
|
287
|
headers={'referer': 'https://addons.mozilla.org/en-US/developers/addon/api/key/?token=' + token}, data=dbase)
|
288
|
|
289
|
json_key, text = r.text.split('name="jwtkey" value="', maxsplit=1)[1].split('"', maxsplit=1)
|
290
|
print('Key:', json_key)
|
291
|
secret = text.split('name="jwtsecret" value="', maxsplit=1)[1].split('"', maxsplit=1)[0]
|
292
|
print('Secret:', secret)
|
293
|
|
294
|
|
295
|
elif 'keys' in options:
|
296
|
if 'delete' in options:
|
297
|
print('KEY DELETION IS CURRENTLY UNSUPPORTED.')
|
298
|
else:
|
299
|
r = req('get', 'https://addons.mozilla.org/en-US/developers/addon/api/key/')
|
300
|
json_key, text = r.text.split('name="jwtkey" value="', maxsplit=1)[1].split('"', maxsplit=1)
|
301
|
print('Key:', json_key)
|
302
|
secret = text.split('name="jwtsecret" value="', maxsplit=1)[1].split('"', maxsplit=1)[0]
|
303
|
print('Secret:', secret)
|
304
|
|
305
|
|
306
|
elif 'delete' in options:
|
307
|
if input('Please type "y" and press return to delete your account') == 'y':
|
308
|
email, pwd = get('e-mail'), get('stretched_password')
|
309
|
|
310
|
data = json.dumps({'email': email, 'authPW': pwd})
|
311
|
auth_header = hawk_header(data, 'POST', '/v1/account/destroy', 'api.accounts.firefox.com', '443')
|
312
|
req('post', 'https://api.accounts.firefox.com/v1/account/destroy',
|
313
|
headers={'authorization': auth_header, 'content-type': 'application/json'}, data=data)
|
314
|
|
315
|
sys.exit(0)
|
316
|
|
317
|
|
318
|
|
319
|
if 'e-mail' in options and 'stretched_password' in options:
|
320
|
auth_header = hawk_header('{}', 'POST', '/v1/session/destroy', 'api.accounts.firefox.com', '443')
|
321
|
r = req('post', 'https://api.accounts.firefox.com/v1/session/destroy',
|
322
|
headers={'authorization': auth_header, 'content-type': 'application/json'}, data='{}')
|