snapshot
This commit is contained in:
108
mtik_cert_pusher/__main__.py
Normal file
108
mtik_cert_pusher/__main__.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
# dlitz 2025
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
from argparse import ArgumentParser
|
||||
from pathlib import Path
|
||||
|
||||
from .cert_util import split_certs
|
||||
from .ssl_util import SSLUtil
|
||||
#from .cert_pusher import MTCertPusher
|
||||
from .routeros_ssh import RouterOS, SSHConnector
|
||||
|
||||
#def generate_random_passphrase():
|
||||
# return os.urandom(64).hex()
|
||||
|
||||
|
||||
def make_arg_parser():
|
||||
if Path(sys.argv[0]).stem == '__main__':
|
||||
prog = __package__
|
||||
else:
|
||||
prog = None
|
||||
parser = ArgumentParser(
|
||||
prog=prog,
|
||||
description="push TLS privkey & certificate to MikroTik RouterOS router"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-k", "--privkey", type=Path, required=True, help="private key file"
|
||||
)
|
||||
parser.add_argument("--cert", type=Path, required=True, help="certificate file")
|
||||
parser.add_argument(
|
||||
"--chain", type=Path, help="separate certificate chain file (optional)"
|
||||
)
|
||||
parser.add_argument("--ssh-config", type=Path, help="ssh config file")
|
||||
parser.add_argument("--ssh-host", required=True, help="target ssh host")
|
||||
parser.add_argument("--ssh-user", help="target ssh user")
|
||||
parser.add_argument("--ssh-port", type=int, help="target ssh port")
|
||||
return parser
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = make_arg_parser()
|
||||
args = parser.parse_args()
|
||||
assert ":" not in args.ssh_host
|
||||
return args, parser
|
||||
|
||||
|
||||
def main():
|
||||
args, parser = parse_args()
|
||||
|
||||
# TODO: Check certificate serial number before attempting to copy cert, and at end.
|
||||
|
||||
privkey_data = args.privkey.read_text()
|
||||
cert_data = args.cert.read_text()
|
||||
chain_data = args.chain.read_text() if args.chain is not None else None
|
||||
|
||||
#key_passphrase = generate_random_passphrase()
|
||||
#chain_certs = split_certs(chain_data)
|
||||
|
||||
ssl_util = SSLUtil()
|
||||
ssh_connector = SSHConnector(host=args.ssh_host, port=args.ssh_port, user=args.ssh_user, ssh_config_path=args.ssh_config)
|
||||
ros_remote = RouterOS(connector=ssh_connector, ssl_util=ssl_util)
|
||||
|
||||
#ros_ssh = RouterOS_SSH(host=args.ssh_host)
|
||||
|
||||
ros_remote.install_key_and_certificates(key=privkey_data, cert=cert_data, chain=chain_data)
|
||||
|
||||
# pkcs12_data = sslutil.export_pkcs12(
|
||||
# privkey_data=privkey_data,
|
||||
# cert_data=cert_data,
|
||||
# chain_data=chain_data,
|
||||
# passphrase=key_passphrase,
|
||||
# )
|
||||
|
||||
#
|
||||
#
|
||||
# with tempfile.NamedTemporaryFile(dir="/dev/shm") as tf:
|
||||
# tf.write(pkcs12_data)
|
||||
# tf.flush()
|
||||
#
|
||||
# ssh_options = [
|
||||
# "-oBatchMode=yes",
|
||||
# "-oControlMaster=no",
|
||||
# ]
|
||||
#
|
||||
# cmd = ["scp", *ssh_options, "-q", tf.name, f"{args.ssh_host}:/cert-pusher-data.p12"]
|
||||
# # print("executing:", shlex.join(cmd))
|
||||
# subprocess.run(cmd, check=True)
|
||||
#
|
||||
# # ros_command = f'/certificate import name=www_ssl_cert file-name=cert-pusher-data.p12 no-key-export=yes passphrase="{key_passphrase}"'
|
||||
# ros_command = f'/certificate import name=www_ssl_cert file-name=cert-pusher-data.p12 no-key-export=yes passphrase="{key_passphrase}"; /file remove [/file find name=cert-pusher-data.p12]'
|
||||
# cmd = [
|
||||
# "ssh",
|
||||
# *ssh_options,
|
||||
# args.ssh_host,
|
||||
# ros_command,
|
||||
# ]
|
||||
# result = subprocess.check_output(cmd, text=True)
|
||||
# assert " files-imported: 1" in result
|
||||
# # print(result)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
57
mtik_cert_pusher/cert_pusher.py
Normal file
57
mtik_cert_pusher/cert_pusher.py
Normal file
@@ -0,0 +1,57 @@
|
||||
#!python3
|
||||
# dlitz 2025-2026
|
||||
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives.hashes import SHA256
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
BestAvailableEncryption,
|
||||
KeySerializationEncryption,
|
||||
PrivateFormat,
|
||||
load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
pkcs12,
|
||||
)
|
||||
|
||||
from .cert_util import split_certs
|
||||
from .routeros_ssh import RouterOS_SSH
|
||||
from .ssl_util import SSLUtil
|
||||
|
||||
|
||||
class MTCertPusher:
|
||||
|
||||
temporary_directory = "/dev/shm"
|
||||
|
||||
def __init__(self, ssl_util: SSLUtil, ros_ssh: RouterOS_SSH):
|
||||
self.ssl_util = ssl_util
|
||||
self.ros = ros_ssh
|
||||
self.tempdir = tempfile.TemporaryDirectory(dir=self.temporary_directory)
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
tempdir = self.tempdir
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
self.tempdir.cleanup()
|
||||
del self.tempdir
|
||||
|
||||
def generate_random_pkcs12_passphrase(self):
|
||||
return os.urandom(64).hex()
|
||||
|
||||
def install_key_and_certificates(
|
||||
self, key: str, cert: str, chain: str | None = None
|
||||
):
|
||||
private_key_obj = load_pem_private_key(key.encode())
|
||||
cert_obj = x509.load_pem_x509_certificate(cert.encode())
|
||||
if cert_obj.public_key() != private_key_obj.public_key():
|
||||
raise ValueError("certificate does not match private key")
|
||||
|
||||
passphrase = self.generate_random_pkcs12_passphrase()
|
||||
p12 = self.ssl_util.create_pkcs12_from_key_and_certificates(key=key, cert=cert, passphrase=passphrase)
|
||||
|
||||
48
mtik_cert_pusher/cert_util.py
Normal file
48
mtik_cert_pusher/cert_util.py
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
# dlitz 2026
|
||||
import base64
|
||||
import re
|
||||
|
||||
CERT_REGEXP = re.compile(
|
||||
r"""
|
||||
(?P<cert>
|
||||
^-----BEGIN\ CERTIFICATE-----\n
|
||||
(?P<b64data>
|
||||
[0-9A-Za-z/+\n]+? # base64 characters and newlines
|
||||
={0,2}\n # base64 padding
|
||||
)
|
||||
^-----END\ CERTIFICATE-----(?:\n|\Z)
|
||||
)
|
||||
""",
|
||||
re.S | re.M | re.X,
|
||||
)
|
||||
|
||||
|
||||
def split_certs(pem_data: str, *, strict: bool = True) -> list[str]:
|
||||
r = CERT_REGEXP
|
||||
if not strict:
|
||||
return [m["cert"] for m in r.finditer(pem_data)]
|
||||
certs = []
|
||||
pos = 0
|
||||
for m in r.finditer(pem_data):
|
||||
if strict and m.start() != pos:
|
||||
raise ValueError(
|
||||
f"certificate data contains extra junk at (position {pos})"
|
||||
)
|
||||
cert = m["cert"]
|
||||
if strict:
|
||||
# Try decoding the base64 data
|
||||
base64.b64decode(m["b64data"])
|
||||
certs.append(cert)
|
||||
pos = m.end()
|
||||
if strict and pos != len(pem_data):
|
||||
raise ValueError(f"extra junk after certificate data (at position {pos})")
|
||||
return certs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pathlib import Path
|
||||
|
||||
# print(split_certs(Path("threecerts.pem").read_text(), strict=False))
|
||||
# print(split_certs(Path("threecerts.pem").read_text(), strict=True))
|
||||
print(split_certs("-----BEGIN CERTIFICATE-----\nAAA=\n-----END CERTIFICATE-----\n"))
|
||||
46
mtik_cert_pusher/pkcs12_export.py
Normal file
46
mtik_cert_pusher/pkcs12_export.py
Normal file
@@ -0,0 +1,46 @@
|
||||
#!python3
|
||||
# dlitz 2025
|
||||
|
||||
import fcntl
|
||||
import re
|
||||
import subprocess
|
||||
from contextlib import ExitStack, contextmanager
|
||||
|
||||
from .ssl_util import SSLUtil
|
||||
|
||||
if __name__ == '__main__':
|
||||
from argparse import ArgumentParser
|
||||
from pathlib import Path
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
parser = ArgumentParser(
|
||||
description="push TLS privkey & certificate to MikroTik RouterOS router"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-k", "--privkey", type=Path, required=True, help="private key file"
|
||||
)
|
||||
parser.add_argument("--cert", type=Path, required=True, help="certificate file")
|
||||
parser.add_argument(
|
||||
"--chain", type=Path, help="separate certificate chain file (optional)"
|
||||
)
|
||||
parser.add_argument("-o", "--output", type=Path, help="output file")
|
||||
args = parser.parse_args()
|
||||
|
||||
privkey_data = args.privkey.read_text()
|
||||
cert_data = args.cert.read_text()
|
||||
chain_data = args.chain.read_text() if args.chain is not None else None
|
||||
|
||||
key_passphrase = getpass.getpass("set the passphrase:")
|
||||
|
||||
pkcs12_data = SSLUtil().export_pkcs12(
|
||||
privkey_data=privkey_data,
|
||||
cert_data=cert_data,
|
||||
chain_data=chain_data,
|
||||
passphrase=key_passphrase,
|
||||
)
|
||||
|
||||
if args.output:
|
||||
args.output.write_bytes(pkcs12_data)
|
||||
else:
|
||||
sys.stdout.buffer.write(pkcs12_data)
|
||||
132
mtik_cert_pusher/routeros_ssh.py
Normal file
132
mtik_cert_pusher/routeros_ssh.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!python3
|
||||
# dlitz 2026
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .ssl_util import SSLUtil
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
|
||||
class Connector:
|
||||
pass
|
||||
|
||||
class SSHConnector(Connector):
|
||||
|
||||
ssh_executable = "ssh"
|
||||
scp_executable = "scp"
|
||||
temporary_directory = "/dev/shm"
|
||||
common_args = ["-oBatchMode=yes", "-oControlMaster=no"]
|
||||
|
||||
def __init__(self, host: str, *, user: str|None=None, port: int|None=None, ssh_config_path: str|None=None, extra_ssh_options=None, extra_ssh_args=None, extra_scp_args=None):
|
||||
self.ssh_host = host
|
||||
self.ssh_user = user
|
||||
self.ssh_port = port
|
||||
self.ssh_config_path = ssh_config_path
|
||||
if extra_ssh_options is None:
|
||||
extra_ssh_options = {}
|
||||
assert isinstance(extra_ssh_options, dict), "extra_ssh_options should be a dict"
|
||||
self.extra_ssh_options = extra_ssh_options
|
||||
self.extra_ssh_args = extra_ssh_args or ()
|
||||
self.extra_scp_args = extra_scp_args or ()
|
||||
|
||||
def _ssh_option_args(self) -> list:
|
||||
result = []
|
||||
if self.ssh_config_path:
|
||||
result.extend(["-F", self.ssh_config_path])
|
||||
if self.ssh_user:
|
||||
result.append("-oUser={self.ssh_user}")
|
||||
if self.ssh_port:
|
||||
result.append("-oPort={self.ssh_port:d}")
|
||||
for k, v in self.extra_ssh_options.items():
|
||||
assert "=" not in k
|
||||
assert k
|
||||
result.append(f"-o{k}={v}")
|
||||
return result
|
||||
|
||||
def ssh_args(self, args, /) -> list:
|
||||
return [
|
||||
self.ssh_executable,
|
||||
*self.common_args,
|
||||
*self._ssh_option_args(),
|
||||
*self.extra_ssh_args,
|
||||
*args,
|
||||
]
|
||||
|
||||
def scp_args(self, args, /) -> list:
|
||||
return [
|
||||
self.scp_executable,
|
||||
*self.common_args,
|
||||
*self._ssh_option_args(),
|
||||
*self.extra_scp_args,
|
||||
*args,
|
||||
]
|
||||
|
||||
def invoke_remote_command(self, cmdline: str, text:bool=False) -> str:
|
||||
cmd = self.ssh_args([self.ssh_host, cmdline])
|
||||
result = subprocess.check_output(cmd, text=text)
|
||||
return result
|
||||
|
||||
def copy_to_remote(self, content: bytes, remote_name: str):
|
||||
with tempfile.NamedTemporaryFile(dir=self.temporary_directory) as tf:
|
||||
tf.write(content)
|
||||
tf.flush()
|
||||
cmd = self.scp_args([
|
||||
"-q",
|
||||
tf.name,
|
||||
f"{self.ssh_host}:{remote_name}",
|
||||
])
|
||||
print("running: ", shlex.join(cmd))
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
class RouterOS:
|
||||
def __init__(self, connector: Connector, ssl_util:SSLUtil):
|
||||
self.connector = connector
|
||||
self.ssl_util = ssl_util
|
||||
|
||||
def get_certificate_info(self):
|
||||
cmdline = ":put [:serialize to=json [/certificate print detail as-value]]"
|
||||
result_json = self.connector.invoke_remote_command(cmdline)
|
||||
return json.loads(result_json)
|
||||
|
||||
def _generate_random_pkcs12_passphrase(self):
|
||||
return os.urandom(32).hex()
|
||||
|
||||
def install_key_and_certificates(
|
||||
self, key: str, cert: str, chain: str,
|
||||
):
|
||||
private_key_obj = load_pem_private_key(key.encode(), password=None)
|
||||
cert_obj = x509.load_pem_x509_certificate(cert.encode())
|
||||
if cert_obj.public_key() != private_key_obj.public_key():
|
||||
raise ValueError("certificate does not match private key")
|
||||
|
||||
fpr_hex = cert_obj.fingerprint(hashes.SHA256()).hex()
|
||||
|
||||
pubkey_obj = cert_obj.public_key()
|
||||
skid_obj = x509.SubjectKeyIdentifier.from_public_key(pubkey_obj)
|
||||
skid_hex = skid_obj.key_identifier.hex()
|
||||
|
||||
remote_filename = f"{fpr_hex}.p12"
|
||||
#remote_name = f"skid_{skid_hex}"
|
||||
remote_name = f"fpr_{fpr_hex}"
|
||||
|
||||
passphrase = self._generate_random_pkcs12_passphrase()
|
||||
p12 = self.ssl_util.create_pkcs12_from_key_and_certificates(key=key, cert=cert, chain=chain, passphrase=passphrase)
|
||||
|
||||
#Path("test.p12").write_bytes(p12) # DEBUG FIXME
|
||||
|
||||
# Copy PKCS12 to remote
|
||||
self.connector.copy_to_remote(p12, remote_filename)
|
||||
cmdline = f'/certificate import no-key-export=yes trusted=no name="{remote_name}" file-name="{remote_filename}" passphrase="{passphrase}"'
|
||||
#print(cmdline)
|
||||
cmd_out = self.connector.invoke_remote_command(cmdline, text=True)
|
||||
assert " files-imported: 1" in cmd_out, cmd_out
|
||||
|
||||
# TODO: Check that certs are installed in remote; set cert
|
||||
|
||||
#cmdline = "/
|
||||
180
mtik_cert_pusher/ssl_util.py
Normal file
180
mtik_cert_pusher/ssl_util.py
Normal file
@@ -0,0 +1,180 @@
|
||||
#!python3
|
||||
# dlitz 2025-2026
|
||||
import contextlib
|
||||
import fcntl
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
BestAvailableEncryption,
|
||||
Encoding,
|
||||
KeySerializationEncryption,
|
||||
PrivateFormat,
|
||||
load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
pkcs12,
|
||||
)
|
||||
|
||||
|
||||
def pipe_with_buffered_data(data, *, text=False, encoding=None, errors=None):
|
||||
# Open a pipe and place a small string into its buffer, then return a file
|
||||
# open for reading the pipe.
|
||||
rfile, wfile = _open_pipes(text=text)
|
||||
try:
|
||||
if text:
|
||||
bdata = data.encode(encoding=wfile.encoding, errors=wfile.errors)
|
||||
else:
|
||||
bdata = data
|
||||
pipe_buffer_size = fcntl.fcntl(wfile.fileno(), fcntl.F_GETPIPE_SZ)
|
||||
if pipe_buffer_size < len(bdata):
|
||||
pipe_buffer_size = fcntl.fcntl(
|
||||
wfile.fileno(), fcntl.F_SETPIPE_SZ, len(bdata)
|
||||
)
|
||||
assert pipe_buffer_size >= len(bdata)
|
||||
wfile.write(data)
|
||||
wfile.close()
|
||||
return rfile
|
||||
except:
|
||||
wfile.close()
|
||||
rfile.close()
|
||||
raise
|
||||
|
||||
|
||||
def _open_pipes(*, text=False, encoding=None, errors=None):
|
||||
rfd, wfd = os.pipe()
|
||||
rfile = wfile = None
|
||||
try:
|
||||
rfile = open(rfd, "r" if text else "rb", encoding=encoding, errors=errors)
|
||||
wfile = open(wfd, "w" if text else "wb", encoding=encoding, errors=errors)
|
||||
return rfile, wfile
|
||||
except:
|
||||
if rfile is not None:
|
||||
rfile.close()
|
||||
else:
|
||||
os.close(rfd)
|
||||
if wfile is not None:
|
||||
wfile.close()
|
||||
else:
|
||||
os.close(wfd)
|
||||
raise
|
||||
|
||||
|
||||
class ResultParseError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SSLUtil:
|
||||
|
||||
openssl_prog = "openssl"
|
||||
|
||||
def cert_fingerprint_sha256(self, pem_cert: str) -> bytes:
|
||||
"""Return the SHA256 fingerprint of the certificate, as bytes."""
|
||||
cert_obj = x509.load_pem_x509_certificate(pem_cert.encode())
|
||||
result = cert_obj.fingerprint(hashes.SHA256())
|
||||
assert isinstance(result, bytes) and len(result) == 32, (result,)
|
||||
return result
|
||||
|
||||
def cert_serial(self, cert: str) -> int:
|
||||
"""Return the serial number of the certificate, as integer. Might be negative."""
|
||||
cert_obj = x509.load_pem_x509_certificate(cert.encode())
|
||||
result = cert_obj.serial_number
|
||||
assert isinstance(result, int), ("serial number not integer?", result)
|
||||
return result
|
||||
|
||||
def cert_skid(self, pem_cert: str) -> bytes:
|
||||
"""Return the SubjectKeyIdentifier of the certificate, as bytes."""
|
||||
cert_obj = x509.load_pem_x509_certificate(pem_cert.encode())
|
||||
pubkey_obj = cert_obj.public_key()
|
||||
skid_obj = x509.SubjectKeyIdentifier.from_public_key(pubkey_obj)
|
||||
assert skid_obj.digest == skid_obj.key_identifier
|
||||
result = skid_obj.key_identifier
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) == 20
|
||||
return result
|
||||
|
||||
def skid_from_pubkey(self, pubkey: str) -> bytes:
|
||||
pubkey_obj = load_pem_public_key(pubkey.encode())
|
||||
result = x509.SubjectKeyIdentifier.from_public_key(pubkey_obj).key_identifier
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) == 20
|
||||
return result
|
||||
|
||||
def skid_from_private_key(self, private_key: str) -> bytes:
|
||||
private_key_obj = load_pem_private_key(private_key.encode())
|
||||
pubkey_obj = private_key_obj.public_key()
|
||||
result = x509.SubjectKeyIdentifier.from_public_key(pubkey_obj).key_identifier
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) == 20
|
||||
return result
|
||||
|
||||
def create_pkcs12_from_key_and_certificates(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
key: str,
|
||||
cert: str,
|
||||
chain: str | None = None,
|
||||
passphrase: str,
|
||||
) -> bytes:
|
||||
private_key_obj = load_pem_private_key(key.encode(), password=None)
|
||||
cert_obj = x509.load_pem_x509_certificate(cert.encode())
|
||||
chain_objs = x509.load_pem_x509_certificates(chain.encode()) if chain else []
|
||||
|
||||
if name is None:
|
||||
pubkey_obj = cert_obj.public_key()
|
||||
skid_obj = x509.SubjectKeyIdentifier.from_public_key(pubkey_obj)
|
||||
skid = skid_obj.key_identifier.hex()
|
||||
fingerprint = cert_obj.fingerprint(hashes.SHA256()).hex()
|
||||
name = f"SKID:{skid} FPR:{fingerprint}"
|
||||
|
||||
result = pkcs12.serialize_key_and_certificates(
|
||||
name=name.encode(),
|
||||
key=private_key_obj,
|
||||
cert=cert_obj,
|
||||
cas=chain_objs,
|
||||
encryption_algorithm=self.pkcs12_encryption_algorithm(passphrase.encode()),
|
||||
)
|
||||
assert isinstance(result, bytes)
|
||||
assert result
|
||||
return result
|
||||
|
||||
# def export_pkcs12(self, privkey_data, cert_data, chain_data, passphrase):
|
||||
# assert re.search(
|
||||
# r"^-----BEGIN(?: (.*))? PRIVATE KEY-----\n", privkey_data, re.M
|
||||
# )
|
||||
# assert re.search(r"^-----BEGIN CERTIFICATE-----\n", cert_data, re.M)
|
||||
# assert "PRIVATE KEY" not in cert_data
|
||||
# assert chain_data is None or "PRIVATE KEY" not in chain_data
|
||||
#
|
||||
# fullchain_data = cert_data + "\n" + (chain_data or "") + "\n"
|
||||
#
|
||||
# all_data = privkey_data + "\n" + fullchain_data
|
||||
#
|
||||
# with pipe_with_buffered_data(passphrase, text=True) as passphrase_r:
|
||||
# cmd = [
|
||||
# self.openssl_prog,
|
||||
# "pkcs12",
|
||||
# "-export",
|
||||
# "-passout",
|
||||
# f"fd:{passphrase_r.fileno():d}",
|
||||
# # "-macalg", "SHA256",
|
||||
# # "-keypbe", "AES-256-CBC",
|
||||
# # "-certpbe", "NONE",
|
||||
# ]
|
||||
# return subprocess.check_output(
|
||||
# cmd, pass_fds=[passphrase_r.fileno()], input=all_data.encode()
|
||||
# )
|
||||
|
||||
def pkcs12_encryption_algorithm(
|
||||
self, passphrase: bytes
|
||||
) -> KeySerializationEncryption:
|
||||
return (
|
||||
PrivateFormat.PKCS12.encryption_builder()
|
||||
.key_cert_algorithm(pkcs12.PBES.PBESv2SHA256AndAES256CBC)
|
||||
.kdf_rounds(20000)
|
||||
.hmac_hash(hashes.SHA256())
|
||||
.build(passphrase)
|
||||
)
|
||||
43
p12test.py
Normal file
43
p12test.py
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
# dlitz 2026
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
PrivateFormat,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
pkcs12,
|
||||
BestAvailableEncryption,
|
||||
KeySerializationEncryption,
|
||||
)
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
privkey_obj = load_pem_private_key(Path("x-private.pem").read_bytes(), password=None)
|
||||
cert_obj = x509.load_pem_x509_certificate(Path("x-cert.pem").read_bytes())
|
||||
chain_objs = x509.load_pem_x509_certificates(Path("threecerts.pem").read_bytes())
|
||||
|
||||
|
||||
def pkcs12_pbes(password: bytes) -> KeySerializationEncryption:
|
||||
return (
|
||||
PrivateFormat.PKCS12.encryption_builder()
|
||||
.kdf_rounds(50000)
|
||||
.key_cert_algorithm(pkcs12.PBES.PBESv2SHA256AndAES256CBC)
|
||||
.hmac_hash(hashes.SHA256())
|
||||
.build(password)
|
||||
)
|
||||
|
||||
|
||||
p12 = pkcs12.serialize_key_and_certificates(
|
||||
name=b"friendly-name",
|
||||
key=privkey_obj,
|
||||
cert=cert_obj,
|
||||
cas=chain_objs,
|
||||
encryption_algorithm=pkcs12_pbes(b"secret"),
|
||||
)
|
||||
|
||||
Path("out.p12").write_bytes(p12)
|
||||
116
pkcs12_export.py
116
pkcs12_export.py
@@ -1,116 +0,0 @@
|
||||
#!python3
|
||||
# dlitz 2025
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from contextlib import ExitStack, contextmanager
|
||||
|
||||
|
||||
class PKCS12Exporter:
|
||||
|
||||
openssl_prog = "openssl"
|
||||
|
||||
def export_pkcs12(self, privkey_data, cert_data, chain_data, passphrase):
|
||||
assert re.search(r"^-----BEGIN (.*) PRIVATE KEY-----\n", privkey_data, re.M)
|
||||
assert re.search(r"^-----BEGIN CERTIFICATE-----\n", cert_data, re.M)
|
||||
assert "PRIVATE KEY" not in cert_data
|
||||
assert chain_data is None or "PRIVATE KEY" not in chain_data
|
||||
|
||||
fullchain_data = cert_data + "\n" + (chain_data or "") + "\n"
|
||||
|
||||
all_data = privkey_data + "\n" + fullchain_data
|
||||
|
||||
with self.pipe_with_buffered_data(passphrase, text=True) as passphrase_r:
|
||||
cmd = [
|
||||
self.openssl_prog,
|
||||
"pkcs12",
|
||||
"-export",
|
||||
"-passout",
|
||||
f"fd:{passphrase_r.fileno():d}",
|
||||
# "-macalg", "SHA256",
|
||||
# "-keypbe", "AES-256-CBC",
|
||||
# "-certpbe", "NONE",
|
||||
]
|
||||
return subprocess.check_output(
|
||||
cmd, pass_fds=[passphrase_r.fileno()], input=all_data.encode()
|
||||
)
|
||||
|
||||
def open_pipes(self, *, text=False, encoding=None, errors=None):
|
||||
rfd, wfd = os.pipe()
|
||||
rfile = wfile = None
|
||||
try:
|
||||
rfile = open(rfd, "r" if text else "rb", encoding=encoding, errors=errors)
|
||||
wfile = open(wfd, "w" if text else "wb", encoding=encoding, errors=errors)
|
||||
return rfile, wfile
|
||||
except:
|
||||
if rfile is not None:
|
||||
rfile.close()
|
||||
else:
|
||||
os.close(rfd)
|
||||
if wfile is not None:
|
||||
wfile.close()
|
||||
else:
|
||||
os.close(wfd)
|
||||
raise
|
||||
|
||||
def pipe_with_buffered_data(self, data, *, text=False, encoding=None, errors=None):
|
||||
# Open a pipe and place a small string into its buffer, then return a file
|
||||
# open for reading the pipe.
|
||||
rfile, wfile = self.open_pipes(text=text)
|
||||
try:
|
||||
if text:
|
||||
bdata = data.encode(encoding=wfile.encoding, errors=wfile.errors)
|
||||
else:
|
||||
bdata = data
|
||||
pipe_buffer_size = fcntl.fcntl(wfile.fileno(), fcntl.F_GETPIPE_SZ)
|
||||
if pipe_buffer_size < len(bdata):
|
||||
pipe_buffer_size = fcntl.fcntl(
|
||||
wfile.fileno(), fcntl.F_SETPIPE_SZ, len(bdata)
|
||||
)
|
||||
assert pipe_buffer_size >= len(bdata)
|
||||
wfile.write(data)
|
||||
wfile.close()
|
||||
return rfile
|
||||
except:
|
||||
wfile.close()
|
||||
rfile.close()
|
||||
raise
|
||||
|
||||
if __name__ == '__main__':
|
||||
from argparse import ArgumentParser
|
||||
from pathlib import Path
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
parser = ArgumentParser(
|
||||
description="push TLS privkey & certificate to MikroTik RouterOS router"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-k", "--privkey", type=Path, required=True, help="private key file"
|
||||
)
|
||||
parser.add_argument("--cert", type=Path, required=True, help="certificate file")
|
||||
parser.add_argument(
|
||||
"--chain", type=Path, help="separate certificate chain file (optional)"
|
||||
)
|
||||
parser.add_argument("-o", "--output", type=Path, help="output file")
|
||||
args = parser.parse_args()
|
||||
|
||||
privkey_data = args.privkey.read_text()
|
||||
cert_data = args.cert.read_text()
|
||||
chain_data = args.chain.read_text() if args.chain is not None else None
|
||||
|
||||
key_passphrase = getpass.getpass("set the passphrase:")
|
||||
|
||||
pkcs12_data = PKCS12Exporter().export_pkcs12(
|
||||
privkey_data=privkey_data,
|
||||
cert_data=cert_data,
|
||||
chain_data=chain_data,
|
||||
passphrase=key_passphrase,
|
||||
)
|
||||
|
||||
if args.output:
|
||||
args.output.write_bytes(pkcs12_data)
|
||||
else:
|
||||
sys.stdout.buffer.write(pkcs12_data)
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# dlitz 2025
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
from argparse import ArgumentParser
|
||||
from pathlib import Path
|
||||
|
||||
from pkcs12_export import PKCS12Exporter
|
||||
|
||||
|
||||
def generate_random_passphrase():
|
||||
return os.urandom(64).hex()
|
||||
|
||||
|
||||
def make_arg_parser():
|
||||
parser = ArgumentParser(
|
||||
description="push TLS privkey & certificate to MikroTik RouterOS router"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-k", "--privkey", type=Path, required=True, help="private key file"
|
||||
)
|
||||
parser.add_argument("--cert", type=Path, required=True, help="certificate file")
|
||||
parser.add_argument(
|
||||
"--chain", type=Path, help="separate certificate chain file (optional)"
|
||||
)
|
||||
parser.add_argument("--ssh-host", required=True, help="target ssh host")
|
||||
return parser
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = make_arg_parser()
|
||||
args = parser.parse_args()
|
||||
assert ":" not in args.ssh_host
|
||||
return args, parser
|
||||
|
||||
|
||||
def main():
|
||||
args, parser = parse_args()
|
||||
|
||||
# TODO: Check certificate serial number before attempting to copy cert, and at end.
|
||||
|
||||
privkey_data = args.privkey.read_text()
|
||||
cert_data = args.cert.read_text()
|
||||
chain_data = args.chain.read_text() if args.chain is not None else None
|
||||
|
||||
key_passphrase = generate_random_passphrase()
|
||||
|
||||
pkcs12_data = PKCS12Exporter().export_pkcs12(
|
||||
privkey_data=privkey_data,
|
||||
cert_data=cert_data,
|
||||
chain_data=chain_data,
|
||||
passphrase=key_passphrase,
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(dir="/dev/shm") as tf:
|
||||
tf.write(pkcs12_data)
|
||||
tf.flush()
|
||||
|
||||
ssh_options = [
|
||||
"-oBatchMode=yes",
|
||||
"-oControlMaster=no",
|
||||
]
|
||||
|
||||
cmd = ["scp", *ssh_options, "-q", tf.name, f"{args.ssh_host}:/cert-pusher-data.p12"]
|
||||
# print("executing:", shlex.join(cmd))
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
# ros_command = f'/certificate import name=www_ssl_cert file-name=cert-pusher-data.p12 no-key-export=yes passphrase="{key_passphrase}"'
|
||||
ros_command = f'/certificate import name=www_ssl_cert file-name=cert-pusher-data.p12 no-key-export=yes passphrase="{key_passphrase}"; /file remove [/file find name=cert-pusher-data.p12]'
|
||||
cmd = [
|
||||
"ssh",
|
||||
*ssh_options,
|
||||
args.ssh_host,
|
||||
ros_command,
|
||||
]
|
||||
result = subprocess.check_output(cmd, text=True)
|
||||
assert " files-imported: 1" in result
|
||||
# print(result)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user