First working version
This commit is contained in:
10
NOTES.txt
10
NOTES.txt
@@ -1,10 +0,0 @@
|
|||||||
# Initial install (generated private key on pusher host)
|
|
||||||
cat fullchain privkey > all.pem
|
|
||||||
scp all.pem arachnia:/
|
|
||||||
ssh arachnia "/certificate import name=www_ssl_cert file-name=all.pem no-key-export=yes; /ip service set www-ssl certificate=www_ssl_cert; /ip service set api-ssl certificate=www_ssl_cert"
|
|
||||||
|
|
||||||
# Subsequent install (renewed on pusher host)
|
|
||||||
cat fullchain > renew.pem
|
|
||||||
scp renew.pem arachnia:/
|
|
||||||
ssh arachnia "/certificate import name=www_ssl_cert file-name=renew.pem"
|
|
||||||
#ssh arachnia "/ip service set www-ssl certificate=www_ssl_cert"
|
|
||||||
@@ -1,107 +1,132 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# dlitz 2025
|
# dlitz 2025
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .cert_util import split_certs
|
from cryptography import x509
|
||||||
from .ssl_util import SSLUtil
|
from cryptography.hazmat.primitives.serialization import (
|
||||||
#from .cert_pusher import MTCertPusher
|
load_pem_private_key,
|
||||||
from .routeros_ssh import RouterOS, SSHConnector
|
load_pem_public_key,
|
||||||
|
)
|
||||||
|
|
||||||
#def generate_random_passphrase():
|
from .connector import SSHConnector
|
||||||
# return os.urandom(64).hex()
|
from .routeros import RouterOS
|
||||||
|
|
||||||
|
|
||||||
def make_arg_parser():
|
def make_arg_parser():
|
||||||
if Path(sys.argv[0]).stem == '__main__':
|
if Path(sys.argv[0]).stem == "__main__":
|
||||||
prog = __package__
|
prog = __package__
|
||||||
else:
|
else:
|
||||||
prog = None
|
prog = None
|
||||||
parser = ArgumentParser(
|
parser = ArgumentParser(
|
||||||
prog=prog,
|
prog=prog,
|
||||||
description="push TLS privkey & certificate to MikroTik RouterOS router"
|
description="push TLS privkey & certificate to MikroTik RouterOS router",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
|
subparsers = parser.add_subparsers(dest="subcommand")
|
||||||
|
|
||||||
|
install_parser = subparsers.add_parser(
|
||||||
|
"install", help="push TLS privkey & certificate to MikroTik RouterOS router"
|
||||||
|
)
|
||||||
|
install_parser.add_argument(
|
||||||
"-k", "--privkey", type=Path, required=True, help="private key file"
|
"-k", "--privkey", type=Path, required=True, help="private key file"
|
||||||
)
|
)
|
||||||
parser.add_argument("--cert", type=Path, required=True, help="certificate file")
|
install_parser.add_argument(
|
||||||
parser.add_argument(
|
"--cert", type=Path, required=True, help="certificate file"
|
||||||
|
)
|
||||||
|
install_parser.add_argument(
|
||||||
"--chain", type=Path, help="separate certificate chain file (optional)"
|
"--chain", type=Path, help="separate certificate chain file (optional)"
|
||||||
)
|
)
|
||||||
parser.add_argument("--ssh-config", type=Path, help="ssh config file")
|
install_parser.add_argument("--ssh-config", type=Path, help="ssh config file")
|
||||||
parser.add_argument("--ssh-host", required=True, help="target ssh host")
|
install_parser.add_argument("--ssh-host", required=True, help="target ssh host")
|
||||||
parser.add_argument("--ssh-user", help="target ssh user")
|
install_parser.add_argument("--ssh-user", help="target ssh user")
|
||||||
parser.add_argument("--ssh-port", type=int, help="target ssh port")
|
install_parser.add_argument("--ssh-port", type=int, help="target ssh port")
|
||||||
|
|
||||||
|
fingerprint_parser = subparsers.add_parser(
|
||||||
|
"fingerprint", aliases=["fpr"], help="calculate fingerprint of certificate(s)"
|
||||||
|
)
|
||||||
|
fingerprint_parser.add_argument(
|
||||||
|
dest="files",
|
||||||
|
metavar="cert.pem",
|
||||||
|
nargs='+',
|
||||||
|
type=Path,
|
||||||
|
help="PEM certificate file to read",
|
||||||
|
)
|
||||||
|
|
||||||
|
skid_parser = subparsers.add_parser(
|
||||||
|
#"skid", help="calculate SubjectKeyIdentifier of certificate(s) or key(s)"
|
||||||
|
"skid", help="show the SubjectKeyIdentifier of certificate(s)"
|
||||||
|
)
|
||||||
|
skid_parser.add_argument(
|
||||||
|
dest="files",
|
||||||
|
#metavar="file.pem",
|
||||||
|
metavar="cert.pem",
|
||||||
|
nargs='+',
|
||||||
|
type=Path,
|
||||||
|
#help="PEM file to read",
|
||||||
|
help="PEM certificate file to read",
|
||||||
|
)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
parser = make_arg_parser()
|
parser = make_arg_parser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
assert ":" not in args.ssh_host
|
|
||||||
return args, parser
|
return args, parser
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args, parser = parse_args()
|
args, parser = parse_args()
|
||||||
|
|
||||||
# TODO: Check certificate serial number before attempting to copy cert, and at end.
|
if args.subcommand == "install":
|
||||||
|
assert ":" not in args.ssh_host
|
||||||
|
|
||||||
privkey_data = args.privkey.read_text()
|
privkey_data = args.privkey.read_text()
|
||||||
cert_data = args.cert.read_text()
|
cert_data = args.cert.read_text()
|
||||||
chain_data = args.chain.read_text() if args.chain is not None else None
|
chain_data = args.chain.read_text() if args.chain is not None else None
|
||||||
|
|
||||||
#key_passphrase = generate_random_passphrase()
|
connector = SSHConnector(
|
||||||
#chain_certs = split_certs(chain_data)
|
host=args.ssh_host,
|
||||||
|
port=args.ssh_port,
|
||||||
|
user=args.ssh_user,
|
||||||
|
ssh_config_path=args.ssh_config,
|
||||||
|
)
|
||||||
|
ros_remote = RouterOS(connector=connector)
|
||||||
|
|
||||||
ssl_util = SSLUtil()
|
fingerprint, host_cert_obj = ros_remote.install_key_and_certificates(
|
||||||
ssh_connector = SSHConnector(host=args.ssh_host, port=args.ssh_port, user=args.ssh_user, ssh_config_path=args.ssh_config)
|
key=privkey_data, cert=cert_data, chain=chain_data
|
||||||
ros_remote = RouterOS(connector=ssh_connector, ssl_util=ssl_util)
|
)
|
||||||
|
|
||||||
#ros_ssh = RouterOS_SSH(host=args.ssh_host)
|
ros_remote.use_certificate(fingerprint)
|
||||||
|
|
||||||
ros_remote.install_key_and_certificates(key=privkey_data, cert=cert_data, chain=chain_data)
|
|
||||||
|
|
||||||
# pkcs12_data = sslutil.export_pkcs12(
|
elif args.subcommand in ("fingerprint", "fpr"):
|
||||||
# privkey_data=privkey_data,
|
for path in args.files:
|
||||||
# cert_data=cert_data,
|
try:
|
||||||
# chain_data=chain_data,
|
for cert_obj in x509.load_pem_x509_certificates(path.read_bytes()):
|
||||||
# passphrase=key_passphrase,
|
print(RouterOS.cert_fingerprint(cert_obj))
|
||||||
# )
|
except Exception as exc:
|
||||||
|
exc.add_note(f"path={path}")
|
||||||
|
raise
|
||||||
|
|
||||||
#
|
elif args.subcommand in ("skid"):
|
||||||
#
|
for path in args.files:
|
||||||
# with tempfile.NamedTemporaryFile(dir="/dev/shm") as tf:
|
try:
|
||||||
# tf.write(pkcs12_data)
|
for cert_obj in x509.load_pem_x509_certificates(path.read_bytes()):
|
||||||
# tf.flush()
|
print(RouterOS.cert_skid(cert_obj))
|
||||||
#
|
except Exception as exc:
|
||||||
# ssh_options = [
|
exc.add_note(f"path={path}")
|
||||||
# "-oBatchMode=yes",
|
raise
|
||||||
# "-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)
|
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(args.subcommand)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
#!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)
|
|
||||||
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/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"))
|
|
||||||
131
mtik_cert_pusher/connector.py
Normal file
131
mtik_cert_pusher/connector.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!python3
|
||||||
|
# dlitz 2026
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path, PosixPath
|
||||||
|
|
||||||
|
remote_name_validation_regex = re.compile(
|
||||||
|
r"""
|
||||||
|
\A
|
||||||
|
[A-Za-z0-9_]
|
||||||
|
[A-Za-z0-9_\.\-]*
|
||||||
|
(?:
|
||||||
|
\.
|
||||||
|
[A-Za-z0-9_]+
|
||||||
|
)?
|
||||||
|
\Z
|
||||||
|
""",
|
||||||
|
re.X,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _private_opener(file, flags):
|
||||||
|
"""Open a file with restrictive permission bits"""
|
||||||
|
return os.open(os.fsencode(file), flags, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
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, capture: bool = False
|
||||||
|
) -> str:
|
||||||
|
cmd = self._ssh_args([self.ssh_host, cmdline])
|
||||||
|
#print("running: ", shlex.join(cmd))
|
||||||
|
if capture:
|
||||||
|
return subprocess.check_output(cmd, text=text)
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
def create_remote_files(self, content_by_name: dict, remote_directory: str):
|
||||||
|
if not content_by_name:
|
||||||
|
raise ValueError("require at least one file to copy")
|
||||||
|
with tempfile.TemporaryDirectory(dir=self.temporary_directory, prefix="mtik-connector-tmp") as td:
|
||||||
|
tempfile_paths = []
|
||||||
|
|
||||||
|
# Write the files to a temporary directory
|
||||||
|
for remote_name, content in content_by_name.items():
|
||||||
|
assert isinstance(remote_name, str)
|
||||||
|
if not remote_name_validation_regex.fullmatch(remote_name):
|
||||||
|
raise ValueError(f"illegal remote filename: {remote_name!r}")
|
||||||
|
tempfile_path = Path(td, remote_name)
|
||||||
|
with open(tempfile_path, "wb", opener=_private_opener) as outfile:
|
||||||
|
outfile.write(content)
|
||||||
|
tempfile_paths.append(tempfile_path)
|
||||||
|
|
||||||
|
# Copy them in a single scp command
|
||||||
|
cmd = self._scp_args(
|
||||||
|
[
|
||||||
|
"-q",
|
||||||
|
*(str(p) for p in tempfile_paths),
|
||||||
|
f"{self.ssh_host}:{remote_directory}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
#print("running: ", shlex.join(cmd))
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
#!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)
|
|
||||||
292
mtik_cert_pusher/routeros.py
Normal file
292
mtik_cert_pusher/routeros.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
#!python3
|
||||||
|
# dlitz 2026
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path, PosixPath
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.types import (
|
||||||
|
CertificatePublicKeyTypes,
|
||||||
|
PrivateKeyTypes,
|
||||||
|
PublicKeyTypes,
|
||||||
|
)
|
||||||
|
from cryptography.hazmat.primitives.serialization import (
|
||||||
|
BestAvailableEncryption,
|
||||||
|
Encoding,
|
||||||
|
PrivateFormat,
|
||||||
|
PublicFormat,
|
||||||
|
load_pem_private_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .connector import Connector
|
||||||
|
|
||||||
|
import_result_regex = re.compile(
|
||||||
|
r"""
|
||||||
|
^
|
||||||
|
\s*
|
||||||
|
(?P<name>
|
||||||
|
certificates-imported
|
||||||
|
| private-keys-imported
|
||||||
|
| files-imported
|
||||||
|
| decryption-failures
|
||||||
|
| keys-with-no-certificate
|
||||||
|
)
|
||||||
|
:\s
|
||||||
|
(?P<count>\d+)
|
||||||
|
\s*
|
||||||
|
$
|
||||||
|
""",
|
||||||
|
re.M | re.X,
|
||||||
|
)
|
||||||
|
|
||||||
|
json_list_regex = re.compile(r"^\[.*\]$")
|
||||||
|
|
||||||
|
fingerprint_regex = re.compile(r"\A[0-9a-f]{64}\Z")
|
||||||
|
|
||||||
|
|
||||||
|
class CertInstallError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InstallInfo:
|
||||||
|
skid_hex: str # SubjectKeyInfo digest (hexadecimal)
|
||||||
|
fingerprint_hex: str | None # fingerprint of cert
|
||||||
|
remote_name: str # remote name
|
||||||
|
remote_filename: str # remote filename
|
||||||
|
content: bytes # file content
|
||||||
|
install_cmd: str # command used to install
|
||||||
|
|
||||||
|
|
||||||
|
class RouterOS:
|
||||||
|
def __init__(self, connector: Connector):
|
||||||
|
self.connector = connector
|
||||||
|
|
||||||
|
def get_certificate_list(self):
|
||||||
|
cmdline = ":put [:serialize to=json [/certificate print detail as-value]]"
|
||||||
|
result_json = self.connector.invoke_remote_command(cmdline, capture=True)
|
||||||
|
return json.loads(result_json)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cert_skid(cls, cert_obj) -> str:
|
||||||
|
skid_bin = cert_obj.extensions.get_extension_for_class(
|
||||||
|
x509.SubjectKeyIdentifier
|
||||||
|
).value.key_identifier
|
||||||
|
return skid_bin.hex().upper()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cert_fingerprint(cls, cert_obj) -> str:
|
||||||
|
return cert_obj.fingerprint(hashes.SHA256()).hex()
|
||||||
|
|
||||||
|
def _make_cert_installinfo(self, cert_obj, privkey_obj=None) -> InstallInfo:
|
||||||
|
# RouterOS indexes cert by their SubjectKeyIdentifier (SKID) so we'll
|
||||||
|
# use that for naming.
|
||||||
|
skid_hex = self.cert_skid(cert_obj)
|
||||||
|
fpr_hex = self.cert_fingerprint(cert_obj)
|
||||||
|
|
||||||
|
remote_name = f"skid_{skid_hex}"
|
||||||
|
remote_filename = f"fpr_{fpr_hex}.pem"
|
||||||
|
|
||||||
|
content = cert_obj.public_bytes(Encoding.PEM)
|
||||||
|
passphrase = None
|
||||||
|
if privkey_obj is not None:
|
||||||
|
assert (
|
||||||
|
cert_obj.public_key() == privkey_obj.public_key()
|
||||||
|
), "private key doesn't match certificate"
|
||||||
|
|
||||||
|
passphrase = os.urandom(32).hex()
|
||||||
|
|
||||||
|
encrypted_private_key = privkey_obj.private_bytes(
|
||||||
|
Encoding.PEM,
|
||||||
|
PrivateFormat.PKCS8,
|
||||||
|
BestAvailableEncryption(passphrase.encode()),
|
||||||
|
)
|
||||||
|
|
||||||
|
content = content.rstrip(b"\n") + b"\n" + encrypted_private_key
|
||||||
|
|
||||||
|
install_cmd = f'/certificate import trusted=no no-key-export=yes name="{remote_name}" file-name="{remote_filename}"'
|
||||||
|
if passphrase is not None:
|
||||||
|
install_cmd += f' passphrase="{passphrase}"'
|
||||||
|
|
||||||
|
return InstallInfo(
|
||||||
|
skid_hex=skid_hex,
|
||||||
|
fingerprint_hex=fpr_hex,
|
||||||
|
remote_name=remote_name,
|
||||||
|
remote_filename=remote_filename,
|
||||||
|
content=content,
|
||||||
|
install_cmd=install_cmd,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dedup_certs(self, cert_objs) -> list:
|
||||||
|
# Dedup certificates by fingerprint
|
||||||
|
cert_by_fpr = {}
|
||||||
|
for cert_obj in cert_objs:
|
||||||
|
fpr = self.cert_fingerprint(cert_obj)
|
||||||
|
cert_by_fpr[fpr] = cert_obj
|
||||||
|
|
||||||
|
# Look for duplicate skids in different certs
|
||||||
|
cert_skids = {}
|
||||||
|
for cert_obj in cert_by_fpr.values():
|
||||||
|
skid = self.cert_skid(cert_obj)
|
||||||
|
if skid in cert_skids:
|
||||||
|
raise ValueError(f"skid conflict: {skid!r}")
|
||||||
|
|
||||||
|
return list(cert_by_fpr.values())
|
||||||
|
|
||||||
|
def install_key_and_certificates(
|
||||||
|
self, key: str, cert: str, chain: str | None = None
|
||||||
|
):
|
||||||
|
if not chain:
|
||||||
|
chain = ""
|
||||||
|
private_key_obj = load_pem_private_key(key.encode(), None)
|
||||||
|
cert_objs = x509.load_pem_x509_certificates((cert + "\n" + chain).encode())
|
||||||
|
cert_objs = self._dedup_certs(cert_objs)
|
||||||
|
|
||||||
|
# Find the certificate that signs our private key
|
||||||
|
public_key_obj = private_key_obj.public_key()
|
||||||
|
(host_cert_obj,) = [
|
||||||
|
cert_obj
|
||||||
|
for cert_obj in cert_objs
|
||||||
|
if cert_obj.public_key() == public_key_obj
|
||||||
|
]
|
||||||
|
chain_cert_objs = [
|
||||||
|
cert_obj
|
||||||
|
for cert_obj in cert_objs
|
||||||
|
if cert_obj.public_key() != public_key_obj
|
||||||
|
]
|
||||||
|
|
||||||
|
private_key_skid = self.cert_skid(host_cert_obj)
|
||||||
|
|
||||||
|
# Build the list of InstallInfo objects, and sets of what we're
|
||||||
|
# expecting to find.
|
||||||
|
install_list = [self._make_cert_installinfo(host_cert_obj, private_key_obj)]
|
||||||
|
install_list += [
|
||||||
|
self._make_cert_installinfo(cert_obj) for cert_obj in chain_cert_objs
|
||||||
|
]
|
||||||
|
|
||||||
|
expected_skids = {info.skid_hex for info in install_list}
|
||||||
|
expected_fingerprints = {info.fingerprint_hex for info in install_list} - {None}
|
||||||
|
|
||||||
|
# Merge the commands into a single command-line, and
|
||||||
|
# add a command to get a list of installed certs at the end.
|
||||||
|
remote_commands = []
|
||||||
|
# workaround for race condition where /certificate/import sometimes
|
||||||
|
# doesn't see the recently-uploaded file
|
||||||
|
remote_commands += [":delay 0.1"]
|
||||||
|
remote_commands += [info.install_cmd for info in install_list]
|
||||||
|
remote_commands += [
|
||||||
|
":put [:serialize to=json [/certificate print detail as-value]]"
|
||||||
|
]
|
||||||
|
remote_cmdline = "\n".join(cmd for cmd in remote_commands)
|
||||||
|
|
||||||
|
# Collect the files to upload
|
||||||
|
remote_files = {info.remote_filename: info.content for info in install_list}
|
||||||
|
expected_files_count = len(cert_objs) + 1
|
||||||
|
|
||||||
|
# Upload the files and run the command
|
||||||
|
self.connector.create_remote_files(remote_files, "")
|
||||||
|
cmd_output = self.connector.invoke_remote_command(
|
||||||
|
remote_cmdline, capture=True, text=True
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
import_result_counts, certlist_output = self._parse_cmd_output(cmd_output)
|
||||||
|
|
||||||
|
if import_result_counts["decryption-failures"] != 0:
|
||||||
|
raise CertInstallError(
|
||||||
|
f"BUG: Private key decryption failed on install; {import_result_counts!r}"
|
||||||
|
)
|
||||||
|
if import_result_counts["keys-with-no-certificate"] != 0:
|
||||||
|
raise CertInstallError(
|
||||||
|
f"BUG: No certificate for private key; {import_result_counts!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
found_fingerprints = set()
|
||||||
|
found_skids = set()
|
||||||
|
found_private_key_skids = set()
|
||||||
|
for cert_detail in certlist_output:
|
||||||
|
assert isinstance(cert_detail["fingerprint"], str)
|
||||||
|
assert isinstance(cert_detail["skid"], str)
|
||||||
|
assert isinstance(cert_detail["private-key"], bool)
|
||||||
|
skid = cert_detail["skid"].upper()
|
||||||
|
found_fingerprints.add(cert_detail["fingerprint"].lower())
|
||||||
|
found_skids.add(skid)
|
||||||
|
if cert_detail["private-key"]:
|
||||||
|
found_private_key_skids.add(skid)
|
||||||
|
|
||||||
|
if private_key_skid not in found_private_key_skids:
|
||||||
|
raise CertInstallError(
|
||||||
|
f"private-key for skid {private_key_skid} was not installed"
|
||||||
|
)
|
||||||
|
missing_skids: set = expected_skids - found_skids
|
||||||
|
missing_fingerprints: set = expected_fingerprints - found_fingerprints
|
||||||
|
if missing_skids or missing_fingerprints:
|
||||||
|
raise CertInstallError(
|
||||||
|
f"some certs were not installed {missing_skids=!r}, {missing_fingerprints=!r}"
|
||||||
|
f",\n{expected_skids=!r}"
|
||||||
|
f",\n{found_skids=!r}"
|
||||||
|
f",\n{expected_fingerprints=!r}"
|
||||||
|
f",\n{found_fingerprints=!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.cert_fingerprint(host_cert_obj), host_cert_obj
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
exc.add_note(f"remote_cmdline={remote_cmdline!r}")
|
||||||
|
exc.add_note(f"cmd_output={cmd_output!r}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def use_certificate(self, fingerprint: str):
|
||||||
|
if not fingerprint_regex.fullmatch(fingerprint):
|
||||||
|
raise ValueError(f"illegal fingerprint {fingerprint!r}")
|
||||||
|
cmds = [
|
||||||
|
f'/ip/service set api-ssl,www-ssl certificate=[/certificate find where fingerprint="{fingerprint}"]',
|
||||||
|
f':put [:serialize to=json value={{[/certificate get [/ip/service get api-ssl certificate] fingerprint],[/certificate get [/ip/service get www-ssl certificate] fingerprint]}}]'
|
||||||
|
]
|
||||||
|
remote_cmdline = "\n".join(cmds)
|
||||||
|
raw_result = self.connector.invoke_remote_command(
|
||||||
|
remote_cmdline, text=True, capture=True
|
||||||
|
)
|
||||||
|
result = json.loads(raw_result)
|
||||||
|
api_fingerprint = bytes.fromhex(result[0][0]).hex()
|
||||||
|
www_fingerprint = bytes.fromhex(result[0][1]).hex()
|
||||||
|
missing = []
|
||||||
|
if api_fingerprint != fingerprint:
|
||||||
|
missing.append("api-ssl")
|
||||||
|
if www_fingerprint != fingerprint:
|
||||||
|
missing.append("www-ssl")
|
||||||
|
if missing:
|
||||||
|
raise CertInstallError(
|
||||||
|
f"certs didn't get installed to {','.join(missing)}:"
|
||||||
|
f" {api_fingerprint=!r}"
|
||||||
|
f", {www_fingerprint=!r}"
|
||||||
|
f", {fingerprint=!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_cmd_output(self, cmd_output: str):
|
||||||
|
import_result_counts = defaultdict(int)
|
||||||
|
certlist_output = None
|
||||||
|
|
||||||
|
for rawline in StringIO(cmd_output):
|
||||||
|
line = rawline.rstrip("\r\n")
|
||||||
|
|
||||||
|
if m := import_result_regex.match(line):
|
||||||
|
import_result_counts[m["name"]] += int(m["count"])
|
||||||
|
elif m := json_list_regex.match(line):
|
||||||
|
assert certlist_output is None, "certificate list json received twice?"
|
||||||
|
certlist_output = json.loads(line)
|
||||||
|
elif not line.strip():
|
||||||
|
# blank line
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unable to parse output line: {line!r}")
|
||||||
|
return import_result_counts, certlist_output
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
#!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 = "/
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
#!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
43
p12test.py
@@ -1,43 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
#!python3
|
|
||||||
# dlitz 2025
|
|
||||||
|
|
||||||
from contextlib import ExitStack, contextmanager
|
|
||||||
import multiprocessing
|
|
||||||
import subprocess
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
|
|
||||||
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 ExitStack() as stack:
|
|
||||||
passphrase_r, passphrase_w = stack.enter_context(self.open_pipes(text=True))
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
self.openssl_prog,
|
|
||||||
"pkcs12",
|
|
||||||
"-export",
|
|
||||||
"-passout", f"fd:{passphrase_r.fileno():d}",
|
|
||||||
"-macalg", "SHA256",
|
|
||||||
"-keypbe", "AES-256-CBC",
|
|
||||||
"-certpbe", "NONE",
|
|
||||||
]
|
|
||||||
|
|
||||||
passphrase_proc = multiprocessing.Process(target=self.data_writer, args=(passphrase_w, passphrase))
|
|
||||||
passphrase_proc.start()
|
|
||||||
passphrase_w.close()
|
|
||||||
|
|
||||||
print(cmd)
|
|
||||||
#subprocess.run(["bash", "-x", "-c", "ls -l /dev/fd/; cat /dev/fd/5; cat /dev/fd/3"], pass_fds=(passphrase_r.fileno(), privkey_r.fileno()))
|
|
||||||
#subprocess.run(cmd, pass_fds=(passphrase_r.fileno(), privkey_r.fileno()), input=fullchain_data.encode())
|
|
||||||
subprocess.run(cmd, pass_fds=(passphrase_r.fileno(), privkey_r.fileno()), input=all_data.encode())
|
|
||||||
|
|
||||||
passphrase_proc.terminate()
|
|
||||||
passphrase_proc.
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def open_pipes(self, *, text=False):
|
|
||||||
rfd, wfd = os.pipe()
|
|
||||||
rfile = wfile = None
|
|
||||||
try:
|
|
||||||
rfile = open(rfd, "r" if text else "rb")
|
|
||||||
wfile = open(wfd, "w" if text else "wb")
|
|
||||||
except:
|
|
||||||
if rfile is not None:
|
|
||||||
rfile.close()
|
|
||||||
else:
|
|
||||||
os.close(rfd)
|
|
||||||
if wfile is not None:
|
|
||||||
wfile.close()
|
|
||||||
else:
|
|
||||||
os.close(wfd)
|
|
||||||
with rfile as r, wfile as w:
|
|
||||||
yield r, w
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def data_writer(cls, file, data):
|
|
||||||
file.write(data)
|
|
||||||
file.close()
|
|
||||||
Reference in New Issue
Block a user