1#!/usr/bin/env python3
2
3import argparse
4import os
5import socket
6import urllib
7
8import requests
9
10try:
11    import redfish
12except ModuleNotFoundError:
13    raise Exception("Please run pip install redfish to run this script.")
14try:
15    from OpenSSL import crypto
16except ImportError:
17    raise Exception("Please run pip install pyOpenSSL to run this script.")
18
19SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
20
21# Script to generate a certificates for a CA, server, and client
22# allowing for client authentication using mTLS certificates.
23# This can then be used to test mTLS client authentication for Redfish
24# and webUI. Note that this requires the pyOpenSSL library to function.
25# TODO: Use EC keys rather than RSA keys.
26
27
28def generateCACert(serial):
29    # CA key
30    key = crypto.PKey()
31    key.generate_key(crypto.TYPE_RSA, 2048)
32
33    # CA cert
34    cert = crypto.X509()
35    cert.set_serial_number(serial)
36    cert.set_version(2)
37    cert.set_pubkey(key)
38
39    cert.set_notBefore(b"19700101000000Z")
40    cert.set_notAfter(b"20700101000000Z")
41
42    caCertSubject = cert.get_subject()
43    caCertSubject.countryName = "US"
44    caCertSubject.stateOrProvinceName = "California"
45    caCertSubject.localityName = "San Francisco"
46    caCertSubject.organizationName = "OpenBMC"
47    caCertSubject.organizationalUnitName = "bmcweb"
48    caCertSubject.commonName = "Test CA"
49    cert.set_issuer(caCertSubject)
50
51    cert.add_extensions(
52        [
53            crypto.X509Extension(
54                b"basicConstraints", True, b"CA:TRUE, pathlen:0"
55            ),
56            crypto.X509Extension(b"keyUsage", True, b"keyCertSign, cRLSign"),
57            crypto.X509Extension(
58                b"subjectKeyIdentifier", False, b"hash", subject=cert
59            ),
60        ]
61    )
62    cert.add_extensions(
63        [
64            crypto.X509Extension(
65                b"authorityKeyIdentifier", False, b"keyid:always", issuer=cert
66            ),
67        ]
68    )
69
70    # sign CA cert with CA key
71    cert.sign(key, "sha256")
72    return key, cert
73
74
75def generateCertCsr(
76    redfishObject, commonName, extensions, caKey, caCert, serial
77):
78
79    try:
80        socket.inet_aton(commonName)
81        commonName = "IP: " + commonName
82    except socket.error:
83        commonName = "DNS: " + commonName
84
85    CSRRequest = {
86        "CommonName": commonName,
87        "City": "San Fransisco",
88        "Country": "US",
89        "Organization": "",
90        "OrganizationalUnit": "",
91        "State": "CA",
92        "CertificateCollection": {
93            "@odata.id": "/redfish/v1/Managers/bmc/NetworkProtocol/HTTPS/Certificates",
94        },
95        "AlternativeNames": [
96            commonName,
97            "DNS: localhost",
98            "IP: 127.0.0.1",
99        ],
100    }
101
102    response = redfishObject.post(
103        "/redfish/v1/CertificateService/Actions/CertificateService.GenerateCSR",
104        body=CSRRequest,
105    )
106
107    if response.status != 200:
108        raise Exception("Failed to create CSR")
109
110    csrString = response.dict["CSRString"]
111
112    return crypto.load_certificate_request(crypto.FILETYPE_PEM, csrString)
113
114
115def generateCert(commonName, extensions, caKey, caCert, serial, csr=None):
116    # key
117
118    # cert
119    cert = crypto.X509()
120    cert.set_serial_number(serial)
121    cert.set_version(2)
122
123    if csr is None:
124        key = crypto.PKey()
125        key.generate_key(crypto.TYPE_RSA, 2048)
126        cert.set_pubkey(key)
127    else:
128        key = None
129        cert.set_subject(csr.get_subject())
130        cert.set_pubkey(csr.get_pubkey())
131
132    cert.set_notBefore(b"19700101000000Z")
133    cert.set_notAfter(b"20700101000000Z")
134
135    certSubject = cert.get_subject()
136    certSubject.countryName = "US"
137    certSubject.stateOrProvinceName = "California"
138    certSubject.localityName = "San Francisco"
139    certSubject.organizationName = "OpenBMC"
140    certSubject.organizationalUnitName = "bmcweb"
141    certSubject.commonName = commonName
142    cert.set_issuer(caCert.get_issuer())
143
144    extensions.extend(
145        [
146            crypto.X509Extension(
147                b"authorityKeyIdentifier", False, b"keyid", issuer=caCert
148            ),
149        ]
150    )
151    cert.add_extensions(extensions)
152    cert.sign(caKey, "sha256")
153    return key, cert
154
155
156def main():
157    parser = argparse.ArgumentParser()
158    parser.add_argument("--host", help="Host to connect to", required=True)
159    parser.add_argument(
160        "--username", help="Username to connect with", default="root"
161    )
162    parser.add_argument(
163        "--password",
164        help="Password for user in order to install certs over Redfish.",
165        default="0penBmc",
166    )
167    args = parser.parse_args()
168    host = args.host
169    username = args.username
170    password = args.password
171    if username == "root" and password == "0penBMC":
172        print(
173            """Note: Using default username 'root' and default password
174            '0penBmc'. Use --username and --password flags to change these,
175            respectively."""
176        )
177    if "//" not in host:
178        host = f"https://{host}"
179    url = urllib.parse.urlparse(host, scheme="https")
180
181    serial = 1000
182    certsDir = os.path.join(SCRIPT_DIR, "certs")
183    print(f"Writing certs to {certsDir}")
184    try:
185        print("Making certs directory.")
186        os.mkdir(certsDir)
187    except OSError as error:
188        if error.errno == 17:
189            print("certs directory already exists. Skipping...")
190        else:
191            print(error)
192
193    cacertFilename = os.path.join(certsDir, "CA-cert.cer")
194    cakeyFilename = os.path.join(certsDir, "CA-key.pem")
195    if os.path.exists(cacertFilename):
196        with open(cacertFilename, "rb") as cacert_file:
197            caCertDump = cacert_file.read()
198        caCert = crypto.load_certificate(crypto.FILETYPE_PEM, caCertDump)
199        with open(cakeyFilename, "rb") as cakey_file:
200            caKeyDump = cakey_file.read()
201        caKey = crypto.load_privatekey(crypto.FILETYPE_PEM, caKeyDump)
202    else:
203
204        caKey, caCert = generateCACert(serial)
205        caKeyDump = crypto.dump_privatekey(crypto.FILETYPE_PEM, caKey)
206        caCertDump = crypto.dump_certificate(crypto.FILETYPE_PEM, caCert)
207        with open(cacertFilename, "wb") as f:
208            f.write(caCertDump)
209            print("CA cert generated.")
210        with open(cakeyFilename, "wb") as f:
211            f.write(caKeyDump)
212            print("CA key generated.")
213    serial += 1
214
215    clientExtensions = [
216        crypto.X509Extension(
217            b"keyUsage",
218            True,
219            b"""digitalSignature,
220                             keyAgreement""",
221        ),
222        crypto.X509Extension(b"extendedKeyUsage", True, b"clientAuth"),
223    ]
224
225    redfishObject = redfish.redfish_client(
226        base_url="https://" + url.netloc,
227        username=username,
228        password=password,
229        default_prefix="/redfish/v1",
230    )
231    redfishObject.login(auth="session")
232
233    clientKey, clientCert = generateCert(
234        username, clientExtensions, caKey, caCert, serial
235    )
236
237    clientKeyDump = crypto.dump_privatekey(crypto.FILETYPE_PEM, clientKey)
238    with open(os.path.join(certsDir, "client-key.pem"), "wb") as f:
239        f.write(clientKeyDump)
240        print("Client key generated.")
241    serial += 1
242    clientCertDump = crypto.dump_certificate(crypto.FILETYPE_PEM, clientCert)
243
244    with open(os.path.join(certsDir, "client-cert.pem"), "wb") as f:
245        f.write(clientCertDump)
246        print("Client cert generated.")
247
248    san_list = [
249        b"DNS: localhost",
250        b"IP: 127.0.0.1",
251    ]
252
253    try:
254        socket.inet_aton(url.hostname)
255        san_list.append(b"IP: " + url.hostname.encode())
256    except socket.error:
257        san_list.append(b"DNS: " + url.hostname.encode())
258
259    serverExtensions = [
260        crypto.X509Extension(
261            b"keyUsage",
262            True,
263            b"digitalSignature, keyAgreement",
264        ),
265        crypto.X509Extension(b"extendedKeyUsage", True, b"serverAuth"),
266        crypto.X509Extension(b"subjectAltName", False, b", ".join(san_list)),
267    ]
268
269    useCSR = True
270
271    if useCSR:
272        csr = generateCertCsr(
273            redfishObject,
274            url.hostname,
275            serverExtensions,
276            caKey,
277            caCert,
278            serial,
279        )
280        serverKey = None
281        serverKeyDumpStr = ""
282    else:
283        csr = None
284    serverKey, serverCert = generateCert(
285        url.hostname, serverExtensions, caKey, caCert, serial, csr=csr
286    )
287    if serverKey is not None:
288        serverKeyDump = crypto.dump_privatekey(crypto.FILETYPE_PEM, serverKey)
289        with open(os.path.join(certsDir, "server-key.pem"), "wb") as f:
290            f.write(serverKeyDump)
291            print("Server key generated.")
292        serverKeyDumpStr = serverKeyDump.decode()
293    serial += 1
294
295    serverCertDump = crypto.dump_certificate(crypto.FILETYPE_PEM, serverCert)
296
297    with open(os.path.join(certsDir, "server-cert.pem"), "wb") as f:
298        f.write(serverCertDump)
299        print("Server cert generated.")
300
301    serverCertDumpStr = serverCertDump.decode()
302
303    print("Generating p12 cert file for browser authentication.")
304    pkcs12Cert = crypto.PKCS12()
305    pkcs12Cert.set_certificate(clientCert)
306    if clientKey:
307        pkcs12Cert.set_privatekey(clientKey)
308    pkcs12Cert.set_ca_certificates([caCert])
309    pkcs12Cert.set_friendlyname(bytes(username, encoding="utf-8"))
310    with open(os.path.join(certsDir, "client.p12"), "wb") as f:
311        f.write(pkcs12Cert.export())
312        print("Client p12 cert file generated and stored in client.p12.")
313        print(
314            "Copy this file to a system with a browser and install the "
315            "cert into the browser."
316        )
317        print(
318            "You will then be able to test redfish and webui "
319            "authentication using this certificate."
320        )
321        print(
322            "Note: this p12 file was generated without a password, so it "
323            "can be imported easily."
324        )
325
326    caCertJSON = {
327        "CertificateString": caCertDump.decode(),
328        "CertificateType": "PEM",
329    }
330    caCertPath = "/redfish/v1/Managers/bmc/Truststore/Certificates"
331    replaceCertPath = "/redfish/v1/CertificateService/Actions/"
332    replaceCertPath += "CertificateService.ReplaceCertificate"
333    print("Attempting to install CA certificate to BMC.")
334
335    response = redfishObject.post(caCertPath, body=caCertJSON)
336    if response.status == 500:
337        print(
338            "An existing CA certificate is likely already installed."
339            " Replacing..."
340        )
341        caCertJSON["CertificateUri"] = {
342            "@odata.id": caCertPath + "/1",
343        }
344
345        response = redfishObject.post(replaceCertPath, body=caCertJSON)
346        if response.status == 200:
347            print("Successfully replaced existing CA certificate.")
348        else:
349            raise Exception(
350                "Could not install or replace CA certificate."
351                "Please check if a certificate is already installed. If a"
352                "certificate is already installed, try performing a factory"
353                "restore to clear such settings."
354            )
355    elif response.status == 200:
356        print("Successfully installed CA certificate.")
357    else:
358        raise Exception("Could not install certificate: " + response.read)
359    serverCertJSON = {
360        "CertificateString": serverKeyDumpStr + serverCertDumpStr,
361        "CertificateUri": {
362            "@odata.id": "/redfish/v1/Managers/bmc/NetworkProtocol/HTTPS/Certificates/1",
363        },
364        "CertificateType": "PEM",
365    }
366
367    print("Replacing server certificate...")
368    response = redfishObject.post(replaceCertPath, body=serverCertJSON)
369    if response.status == 200:
370        print("Successfully replaced server certificate.")
371    else:
372        raise Exception("Could not replace certificate: " + response.read)
373    tlsPatchJSON = {"Oem": {"OpenBMC": {"AuthMethods": {"TLS": True}}}}
374    print("Ensuring TLS authentication is enabled.")
375    response = redfishObject.patch(
376        "/redfish/v1/AccountService", body=tlsPatchJSON
377    )
378    if response.status == 200:
379        print("Successfully enabled TLS authentication.")
380    else:
381        raise Exception("Could not enable TLS auth: " + response.read)
382    redfishObject.logout()
383    print("Testing redfish TLS authentication with generated certs.")
384    response = requests.get(
385        f"https://{url.netloc}/redfish/v1/SessionService/Sessions",
386        verify=os.path.join(certsDir, "CA-cert.cer"),
387        cert=(
388            os.path.join(certsDir, "client-cert.pem"),
389            os.path.join(certsDir, "client-key.pem"),
390        ),
391    )
392    response.raise_for_status()
393    print("Redfish TLS authentication success!")
394
395
396if __name__ == "__main__":
397    main()
398