Skip to content

Instantly share code, notes, and snippets.

@joba-1
Forked from shtrom/fritz_cert_upload.py
Created August 3, 2018 23:41
Show Gist options
  • Select an option

  • Save joba-1/6b2fe7294ebda3f6a5d058c6fd9ea44a to your computer and use it in GitHub Desktop.

Select an option

Save joba-1/6b2fe7294ebda3f6a5d058c6fd9ea44a to your computer and use it in GitHub Desktop.
Upload a TLS key and cert to a FRITZ!Box, in pretty Python
#!/usr/bin/env python
"""
Upload a TLS key and cert to a FRITZ!Box, in pretty Python
Copyright (C) 2018 Olivier Mehani <shtrom@ssji.net>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
"""
from hashlib import md5
import requests
import re
class FritzBoxAuthService:
'''A service to authenticate and retrieve a session ID from a FRITZ!Box
Based on hsmade/get_sid.py [0].
[0] https://gist.github.com/hsmade/b9e8aed176be28abe7a2e5bdbec52a8d'''
INVALID_SID = '0000000000000000'
@classmethod
def password_auth(cls, host, username, password):
'''Authenticate using a password to get a token, returns a SID'''
challenge = cls.get_challenge(host)
token = cls.compute_token(challenge, password)
return cls.token_auth(host, username, token)
@classmethod
def get_challenge(cls, host):
'''Get the challenge, do very ugly xml parsing'''
return requests.get('http://{host}/login_sid.lua'.format(host=host)).content.split('Challenge>')[1].split('<')[0]
@classmethod
def compute_token(cls, challenge, password):
'''Calculate the response token'''
hash = md5(
'{challenge}-{password}'.format(
challenge=challenge,
password=password,
).encode('utf16')[2:]
).hexdigest()
token = '{challenge}-{hash}'.format(
challenge=challenge,
hash=hash,
)
return token
@classmethod
def token_auth(cls, host, username, token):
'''Authenticate using a token, returns a SID'''
response = requests.get('http://{host}/login_sid.lua?sid={sid}username={username}&response={token}'.format(
host=host,
sid=cls.INVALID_SID,
username=username,
token=token,
))
sid = response.content.split('<SID>')[1].split('<')[0]
if sid == cls.INVALID_SID:
raise FritzBoxLoginException('Failed to get valid SID')
return sid
class FritzBoxLoginException(Exception):
pass
class FritzBoxCertService:
'''Service allowing to upload a TLS key and certificate to a FRITZ!Box
With inspiration from wikrie/fritzbox-cert-update.sh [0].
[0] https://gist.github.com/wikrie/f1d5747a714e0a34d0582981f7cb4cfb'''
@classmethod
def upload_key_cert(cls, host, sid, key, cert, key_password=None):
'''Upload the key and certificate using a valid SID'''
params = {
'sid': sid,
}
if key_password is not None:
# XXX: If it is unconditionally included, even if empty,
# this seems to throw the upload off. Could also be an ordering issue,
# as request puts this parameters first, before the sid
params['BoxCertPassword'] = key_password
certfile = {
'BoxCertImportFile': (
'BoxCert.pem',
'{key}{cert}'.format(
key=key,
cert=cert
),
'application/x-x509-ca-cert',
),
}
response = requests.post(
'http://{host}/cgi-bin/firmwarecfg'.format(host=host),
data=params,
files=certfile
)
if response.status_code != 200:
raise FritzBoxCertUploadException('Failed to upload certificate')
for line in response.text.split('\n'):
if re.search('SSL', line):
return line
elif re.search('ErrorMsg', line):
raise FritzBoxCertUploadException(
line.split('"ErrorMsg">')[1].split('</')[0]
)
raise FritzBoxCertUploadException('Uploaded certificate, but the FRITZ!Box did not acknowledge the attempt')
class FritzBoxCertUploadException(Exception):
pass
if __name__ == '__main__':
import argparse
from getpass import getpass
import sys
def parse_arguments():
LE_PATH='/etc/letsencrypt/live/{domain}'
parser = argparse.ArgumentParser(description='Upload Let\'s Encrypt certificate to FRITZ!Box')
parser.add_argument('-H', '--host', default='fritz.box')
parser.add_argument('-U', '--username', default='')
parser.add_argument('-p', '--password', default='')
parser.add_argument('-P', '--ask-password', default=False)
parser.add_argument('-d', '--domain',
help='Look for Let\'s Encrypt keys in ' + LE_PATH.format(domain="DOMAIN"))
parser.add_argument('-K', '--key-file')
parser.add_argument('-f', '--key-passphrase', default=None)
parser.add_argument('-F', '--ask-key-passphrase', default=False)
parser.add_argument('-C', '--cert-file')
args = vars(parser.parse_args())
if args['ask_password']:
args['password'] = getpass('Password for {username}@{host}: '.format(
username=args['username'],
host=args['host']
))
if args['domain'] is not None:
certpath = LE_PATH.format(domain=args['domain'])
args['key_file'] = '{certpath}/privkey.pem'.format(certpath=certpath)
args['cert_file'] = '{certpath}/fullchain.pem'.format(certpath=certpath)
if args['key_file'] is None or args['cert_file'] is None:
sys.exit(1)
if args['ask_key_passphrase']:
args['key_passphrase'] = getpass('Passphrase for {key}: '.format(
key=args['key_file']
))
return args
args = parse_arguments()
sid = FritzBoxAuthService.password_auth(
args['host'],
args['username'],
args['password']
)
with open(args['key_file']) as key:
with open(args['cert_file']) as cert:
result = FritzBoxCertService.upload_key_cert(args['host'],
sid,
key.read(),
cert.read(),
args['key_passphrase'])
print(result)
@joba-1

joba-1 commented Oct 15, 2018

Copy link
Copy Markdown
Author

for some reason, this does not work for me. I use the bash script in the other gist now

@guillempages

guillempages commented Mar 10, 2020

Copy link
Copy Markdown

Using python3, replacing "content" with "text" on both lines 39 and 65, and adding the missing "&" between {sid} and username in line 59 worked for me on a Fritzbox 6590 with Fritz!OS 7.12.

Thanks a lot for the script!

@joba-1

joba-1 commented Sep 11, 2020

Copy link
Copy Markdown
Author

Thanks for the feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment