The branch, master has been updated
       via  d3aa479f962 docs-xml: Document samba-tool subcommand to generate 
Certificate Signing Requests
       via  db6f50b7cff samba-tool: Add subcommand to generate Certificate 
Signing Requests with SID extension
       via  dbdd6952b6e python: Factor out asn.1 methods into their own module
       via  4c5f77ded68 samba-tool: Fix comments
       via  34431b0d4fa python:tests: Fix code spelling
      from  9bbdfee7f2f vfs_recycle: Make recycle:touch/touch_mtime work again 
if recycle:keeptree is set

https://git.samba.org/?p=samba.git;a=shortlog;h=master


- Log -----------------------------------------------------------------
commit d3aa479f962574c56f754bc407763f9a7ec9778c
Author: Jennifer Sutton <[email protected]>
Date:   Mon Nov 3 16:50:52 2025 +1300

    docs-xml: Document samba-tool subcommand to generate Certificate Signing 
Requests
    
    Signed-off-by: Jennifer Sutton <[email protected]>
    Reviewed-by: Douglas Bagnall <[email protected]>
    Reviewed-by: Gary Lockyer <[email protected]>
    
    Autobuild-User(master): Douglas Bagnall <[email protected]>
    Autobuild-Date(master): Wed Nov  5 05:13:01 UTC 2025 on atb-devel-224

commit db6f50b7cff616b4d67ec62dab6e0208f5cbb79d
Author: Jennifer Sutton <[email protected]>
Date:   Wed Oct 8 14:34:25 2025 +1300

    samba-tool: Add subcommand to generate Certificate Signing Requests with 
SID extension
    
    Signed-off-by: Jennifer Sutton <[email protected]>
    Reviewed-by: Douglas Bagnall <[email protected]>
    Reviewed-by: Gary Lockyer <[email protected]>

commit dbdd6952b6eed586e502d7c26af0b3e233e188a9
Author: Jennifer Sutton <[email protected]>
Date:   Mon Nov 3 10:45:44 2025 +1300

    python: Factor out asn.1 methods into their own module
    
    Signed-off-by: Jennifer Sutton <[email protected]>
    Reviewed-by: Douglas Bagnall <[email protected]>
    Reviewed-by: Gary Lockyer <[email protected]>

commit 4c5f77ded680b58ddb4078636331aba29a1c72c1
Author: Jennifer Sutton <[email protected]>
Date:   Wed Oct 8 10:58:53 2025 +1300

    samba-tool: Fix comments
    
    Signed-off-by: Jennifer Sutton <[email protected]>
    Reviewed-by: Douglas Bagnall <[email protected]>
    Reviewed-by: Gary Lockyer <[email protected]>

commit 34431b0d4fad2ff38a0fde6b3b18e67c0d4e7342
Author: Jennifer Sutton <[email protected]>
Date:   Fri Oct 24 12:25:15 2025 +1300

    python:tests: Fix code spelling
    
    Signed-off-by: Jennifer Sutton <[email protected]>
    Reviewed-by: Douglas Bagnall <[email protected]>
    Reviewed-by: Gary Lockyer <[email protected]>

-----------------------------------------------------------------------

Summary of changes:
 docs-xml/manpages/samba-tool.8.xml                 |  40 ++++
 python/samba/asn1.py                               | 103 ++++++++
 python/samba/generate_csr.py                       | 266 +++++++++++++++++++++
 python/samba/netcmd/computer.py                    |   2 +
 python/samba/netcmd/computer_generate_csr.py       |  93 +++++++
 python/samba/netcmd/computer_keytrust.py           |   2 +-
 python/samba/netcmd/user/__init__.py               |   2 +
 python/samba/netcmd/user/generate_csr.py           |  88 +++++++
 python/samba/netcmd/user/keytrust.py               |   2 +-
 .../tests/krb5/pkinit_certificate_mapping_tests.py |   9 +-
 python/samba/tests/krb5/pkinit_tests.py            |  10 +-
 python/samba/tests/krb5/raw_testcase.py            |  74 +-----
 python/samba/tests/samba_tool/user_generate_csr.py | 154 ++++++++++++
 python/samba/tests/samba_tool/user_keytrust.py     |   2 +-
 source4/selftest/tests.py                          |   1 +
 15 files changed, 766 insertions(+), 82 deletions(-)
 create mode 100644 python/samba/asn1.py
 create mode 100644 python/samba/generate_csr.py
 create mode 100644 python/samba/netcmd/computer_generate_csr.py
 create mode 100644 python/samba/netcmd/user/generate_csr.py
 create mode 100644 python/samba/tests/samba_tool/user_generate_csr.py


Changeset truncated at 500 lines:

diff --git a/docs-xml/manpages/samba-tool.8.xml 
b/docs-xml/manpages/samba-tool.8.xml
index b27b168f471..9f6deee342e 100644
--- a/docs-xml/manpages/samba-tool.8.xml
+++ b/docs-xml/manpages/samba-tool.8.xml
@@ -384,6 +384,26 @@ The <constant>--verbose</constant> includes more, probably 
useless, information.
 </variablelist>
 </refsect3>
 
+<refsect3>
+       <title>computer generate-csr <replaceable>computername</replaceable>
+       <replaceable>subject_name</replaceable>
+       <replaceable>private_key_filename</replaceable>
+       <replaceable>output_filename</replaceable> [options]</title>
+       <para>Generate a PEM‐encoded Certificate Signing Request for a 
computer.</para>
+
+       <variablelist>
+       <!--Options-->
+         <varlistentry>
+           <term>--private-key-encoding</term>
+           <listitem><para>Specify which encoding the private key uses. 
Default is 'auto'.</para></listitem>
+         </varlistentry>
+         <varlistentry>
+           <term>--private-key-pass</term>
+           <listitem><para>Provide a password to decrypt the private 
key.</para></listitem>
+         </varlistentry>
+       </variablelist>
+</refsect3>
+
 
 <refsect2>
        <title>contact</title>
@@ -4482,6 +4502,26 @@ The <constant>--verbose</constant> includes more, 
probably useless, information.
 </variablelist>
 </refsect3>
 
+<refsect3>
+       <title>user generate-csr <replaceable>username</replaceable>
+       <replaceable>subject_name</replaceable>
+       <replaceable>private_key_filename</replaceable>
+       <replaceable>output_filename</replaceable> [options]</title>
+       <para>Generate a PEM‐encoded Certificate Signing Request for a 
user.</para>
+
+       <variablelist>
+       <!--Options-->
+         <varlistentry>
+           <term>--private-key-encoding</term>
+           <listitem><para>Specify which encoding the private key uses. 
Default is 'auto'.</para></listitem>
+         </varlistentry>
+         <varlistentry>
+           <term>--private-key-pass</term>
+           <listitem><para>Provide a password to decrypt the private 
key.</para></listitem>
+         </varlistentry>
+       </variablelist>
+</refsect3>
+
 
 <refsect2>
        <title>vampire [options] <replaceable>domain</replaceable></title>
diff --git a/python/samba/asn1.py b/python/samba/asn1.py
new file mode 100644
index 00000000000..c15fa07add4
--- /dev/null
+++ b/python/samba/asn1.py
@@ -0,0 +1,103 @@
+# Unix SMB/CIFS implementation.
+# Copyright (C) Catalyst.Net Ltd 2025
+#
+#
+# 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 3 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.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+#
+
+"""ASN.1 module"""
+
+import math
+from typing import Optional
+
+
+class Asn1Error(Exception):
+    pass
+
+
+def length_in_bytes(value: int) -> int:
+    """Return the length in bytes of an integer once it is encoded as
+    bytes."""
+
+    if value < 0:
+        raise Asn1Error("value must be positive")
+    if not isinstance(value, int):
+        raise Asn1Error("value must be an integer")
+
+    length_in_bits = max(1, math.log2(value + 1))
+    length_in_bytes = math.ceil(length_in_bits / 8)
+    return length_in_bytes
+
+
+def bytes_from_int(value: int, *, length: Optional[int] = None) -> bytes:
+    """Return an integer encoded big-endian into bytes of an optionally
+    specified length.
+    """
+    if length is None:
+        length = length_in_bytes(value)
+    return value.to_bytes(length, "big")
+
+
+def int_from_bytes(data: bytes) -> int:
+    """Return an integer decoded from bytes in big-endian format."""
+    return int.from_bytes(data, "big")
+
+
+def int_from_bit_string(string: str) -> int:
+    """Return an integer decoded from a bitstring."""
+    return int(string, base=2)
+
+
+def bit_string_from_int(value: int) -> str:
+    """Return a bitstring encoding of an integer."""
+
+    string = f"{value:b}"
+
+    # The bitstring must be padded to a multiple of 8 bits in length, or
+    # pyasn1 will interpret it incorrectly (as if the padding bits were
+    # present, but on the wrong end).
+    length = len(string)
+    padding_len = math.ceil(length / 8) * 8 - length
+    return "0" * padding_len + string
+
+
+def bit_string_from_bytes(data: bytes) -> str:
+    """Return a bitstring encoding of bytes in big-endian format."""
+    value = int_from_bytes(data)
+    return bit_string_from_int(value)
+
+
+def bytes_from_bit_string(string: str) -> bytes:
+    """Return big-endian format bytes encoded from a bitstring."""
+    value = int_from_bit_string(string)
+    length = math.ceil(len(string) / 8)
+    return value.to_bytes(length, "big")
+
+
+def asn1_length(data: bytes) -> bytes:
+    """Return the ASN.1 encoding of the length of some data."""
+
+    length = len(data)
+
+    if length <= 0:
+        raise Asn1Error("length must be greater than zero")
+    if length < 0x80:
+        return bytes([length])
+
+    encoding_len = length_in_bytes(length)
+    if encoding_len >= 0x80:
+        raise Asn1Error("item is too long to be ASN.1 encoded")
+
+    data = bytes_from_int(length, length=encoding_len)
+    return bytes([0x80 | encoding_len]) + data
diff --git a/python/samba/generate_csr.py b/python/samba/generate_csr.py
new file mode 100644
index 00000000000..a486e844b93
--- /dev/null
+++ b/python/samba/generate_csr.py
@@ -0,0 +1,266 @@
+# Generate a Certificate Signing Request for a certificate
+#
+# Copyright (C) Catalyst.Net Ltd 2025
+#
+# 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 3 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.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+
+from typing import Optional
+
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
+from cryptography.hazmat.primitives.serialization import (
+    load_der_private_key,
+    load_pem_private_key,
+)
+from cryptography.x509.base import CertificateSigningRequest
+from samba import asn1, ldb
+from samba.samdb import SamDB
+
+from samba.domain.models import User
+
+
+ID_PKINIT_MS_SAN = x509.ObjectIdentifier("1.3.6.1.4.1.311.20.2.3")
+szOID_NTDS_CA_SECURITY_EXT = x509.ObjectIdentifier("1.3.6.1.4.1.311.25.2")
+
+
+# As the version of python3-cryptography used in CI is too old to include the
+# method x509.Name.from_rfc4514_string(), we must implement it ourselves.
+def x509_name_from_rfc4514_string(rfc4514_string: str) -> x509.Name:
+    # Derived from https://datatracker.ietf.org/doc/html/rfc4514#page-7
+    name_oid_map = {
+        "CN": x509.NameOID.COMMON_NAME,
+        "L": x509.NameOID.LOCALITY_NAME,
+        "ST": x509.NameOID.STATE_OR_PROVINCE_NAME,
+        "O": x509.NameOID.ORGANIZATION_NAME,
+        "OU": x509.NameOID.ORGANIZATIONAL_UNIT_NAME,
+        "C": x509.NameOID.COUNTRY_NAME,
+        "STREET": x509.NameOID.STREET_ADDRESS,
+        "DC": x509.NameOID.DOMAIN_COMPONENT,
+        "UID": x509.NameOID.USER_ID,
+    }
+
+    def name_to_name_oid(name: str) -> x509.ObjectIdentifier:
+        try:
+            return name_oid_map[name]
+        except KeyError:
+            raise ValueError(f"Unknown component ‘{name}’ in RFC4514 string")
+
+    try:
+        dn = ldb.Dn(ldb.Ldb(), rfc4514_string)
+    except ValueError:
+        raise ValueError("Unable to parse RFC4514 string as DN")
+
+    return x509.Name([
+        x509.RelativeDistinguishedName([
+            x509.NameAttribute(
+                name_to_name_oid(dn.get_component_name(i)), 
dn.get_component_value(i)
+            )
+        ])
+        for i in reversed(range(len(dn)))
+    ])
+
+
+def get_private_key(
+    data: bytes, encoding: Optional[str] = None, password: Optional[str] = None
+) -> RSAPrivateKey:
+    """decode a key in PEM or DER format.
+
+    So far only RSA keys are supported.
+    """
+    encoded_password = None
+    if password is not None:
+        encoded_password = password.encode("utf-8")
+
+    if encoding is None:
+        if data[:11] == b"-----BEGIN ":
+            encoding = "PEM"
+        else:
+            encoding = "DER"
+
+    encoding = encoding.upper()
+
+    # The cryptography module also supports ssh keys, PKCS1, and other formats,
+    # as well as non-RSA keys. It might not be wise to tolerate all of this, 
but
+    # we can do it by adding to key_fns here.
+    if encoding == "PEM":
+        key_fns = [load_pem_private_key]
+    elif encoding == "DER":
+        key_fns = [load_der_private_key]
+    else:
+        raise ValueError(
+            f"Private key encoding '{encoding}' not supported (try 'PEM' or 
'DER')"
+        )
+
+    key = None
+    for fn in key_fns:
+        try:
+            key = fn(data, encoded_password)
+            break
+        except ValueError:
+            continue
+        except TypeError:
+            if password is None:
+                raise ValueError("No password supplied to decrypt private key")
+            else:
+                raise ValueError("Password supplied but private key isn’t 
encrypted")
+
+    if key is None:
+        raise ValueError("could not decode private key")
+
+    if not isinstance(key, RSAPrivateKey):
+        raise ValueError(f"Currently only RSA Private Keys are supported (not 
'{key}')")
+
+    return key
+
+
+def generate_csr(
+    samdb: SamDB,
+    user: User,
+    subject_name: str,
+    private_key_filename: str,
+    *,
+    private_key_encoding: Optional[str] = "auto",
+    private_key_pass: Optional[str] = None,
+) -> CertificateSigningRequest:
+    if private_key_encoding == "auto":
+        private_key_encoding = None
+
+    certificate_signature = hashes.SHA256
+
+    account_name = user.account_name
+    if user.user_principal_name is not None:
+        account_upn = user.user_principal_name
+    else:
+        realm = samdb.domain_dns_name()
+        account_upn = f"{account_name}@{realm.lower()}"
+
+    builder = x509.CertificateSigningRequestBuilder()
+    # Add the subject name.
+    builder = builder.subject_name(x509_name_from_rfc4514_string(subject_name))
+
+    with open(private_key_filename, "rb") as private_key_file:
+        private_key_bytes = private_key_file.read()
+
+    private_key = get_private_key(
+        private_key_bytes, encoding=private_key_encoding, 
password=private_key_pass
+    )
+    public_key = private_key.public_key()
+
+    # Add the SubjectAlternativeName. Windows uses this to map the account
+    # to the certificate.
+
+    encoded_upn = account_upn.encode("utf-8")
+    encoded_upn = bytes([0x0C]) + asn1.asn1_length(encoded_upn) + encoded_upn
+
+    ms_upn_san = x509.OtherName(ID_PKINIT_MS_SAN, encoded_upn)
+    alt_names = [ms_upn_san]
+    builder = builder.add_extension(
+        x509.SubjectAlternativeName(alt_names),
+        critical=False,
+    )
+
+    builder = builder.add_extension(
+        x509.BasicConstraints(ca=False, path_length=None),
+        critical=True,
+    )
+
+    # The key identifier is used to identify the certificate.
+    subject_key_id = x509.SubjectKeyIdentifier.from_public_key(public_key)
+    builder = builder.add_extension(
+        subject_key_id,
+        critical=True,
+    )
+
+    # Add the key usages for which this certificate is valid. Windows
+    # doesn’t actually require this extension to be present.
+    builder = builder.add_extension(
+        # Heimdal requires that the certificate be valid for digital
+        # signatures.
+        x509.KeyUsage(
+            digital_signature=True,
+            content_commitment=False,
+            key_encipherment=False,
+            data_encipherment=False,
+            key_agreement=False,
+            key_cert_sign=False,
+            crl_sign=False,
+            encipher_only=False,
+            decipher_only=False,
+        ),
+        critical=True,
+    )
+
+    # Windows doesn’t require this extension to be present either; but if
+    # it is, Windows will not accept the certificate unless either client
+    # authentication or smartcard logon is specified, returning
+    # KDC_ERR_INCONSISTENT_KEY_PURPOSE otherwise.
+    builder = builder.add_extension(
+        x509.ExtendedKeyUsage([
+            x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
+        ]),
+        critical=False,
+    )
+
+    # If the certificate predates (as ours does) the existence of the
+    # account that presents it Windows will refuse to accept it unless
+    # there exists a strong mapping from one to the other. This strong
+    # mapping will in this case take the form of a certificate extension
+    # described in [MS-WCCE] 2.2.2.7.7.4 (szOID_NTDS_CA_SECURITY_EXT) and
+    # containing the account’s SID.
+
+    # Encode this structure manually until we are able to produce the same
+    # ASN.1 encoding that Windows does.
+
+    encoded_sid = user.object_sid.encode("utf-8")
+
+    # The OCTET STRING tag, followed by length and encoded SID…
+    security_ext = bytes([0x04]) + asn1.asn1_length(encoded_sid) + 
(encoded_sid)
+
+    # …enclosed in a construct tagged with the application-specific value
+    # 0…
+    security_ext = bytes([0xA0]) + asn1.asn1_length(security_ext) + 
(security_ext)
+
+    # …preceded by the extension OID…
+
+    encoded_oid = bytes.fromhex("060a2b060104018237190201")
+    security_ext = encoded_oid + security_ext
+
+    # …and another application-specific tag 0…
+    # (This is the part about which I’m unsure. This length is not just of
+    # the OID, but of the entire structure so far, as if there’s some
+    # nesting going on.  So far I haven’t been able to replicate this with
+    # pyasn1.)
+    security_ext = bytes([0xA0]) + asn1.asn1_length(security_ext) + 
(security_ext)
+
+    # …all enclosed in a structure with a SEQUENCE tag.
+    security_ext = bytes([0x30]) + asn1.asn1_length(security_ext) + 
(security_ext)
+
+    # Add the security extension to the certificate.
+    builder = builder.add_extension(
+        x509.UnrecognizedExtension(
+            szOID_NTDS_CA_SECURITY_EXT,
+            security_ext,
+        ),
+        critical=False,
+    )
+
+    # Sign the certificate with the user’s private key.
+    return builder.sign(
+        private_key=private_key,
+        algorithm=certificate_signature(),
+        backend=default_backend(),
+    )
diff --git a/python/samba/netcmd/computer.py b/python/samba/netcmd/computer.py
index cd5389cf8ec..39f6c627039 100644
--- a/python/samba/netcmd/computer.py
+++ b/python/samba/netcmd/computer.py
@@ -36,6 +36,7 @@ from samba.samdb import SamDB
 from samba.common import get_bytes
 from subprocess import check_call, CalledProcessError
 from . import common
+from .computer_generate_csr import cmd_computer_generate_csr
 from .computer_keytrust import cmd_computer_keytrust
 
 
@@ -723,6 +724,7 @@ class cmd_computer(SuperCommand):
     subcommands["create"] = cmd_computer_add()
     subcommands["delete"] = cmd_computer_delete()
     subcommands["edit"] = cmd_computer_edit()
+    subcommands["generate-csr"] = cmd_computer_generate_csr()
     subcommands["list"] = cmd_computer_list()
     subcommands["show"] = cmd_computer_show()
     subcommands["move"] = cmd_computer_move()
diff --git a/python/samba/netcmd/computer_generate_csr.py 
b/python/samba/netcmd/computer_generate_csr.py
new file mode 100644
index 00000000000..094002e359d
--- /dev/null
+++ b/python/samba/netcmd/computer_generate_csr.py
@@ -0,0 +1,93 @@
+# samba-tool commands to generate a Certificate Signing Request for a 
computer’s
+# certificate
+#
+# Copyright (C) Catalyst.Net Ltd 2025
+#
+# 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 3 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.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+from typing import Optional
+
+from cryptography.hazmat.primitives import serialization
+import samba.getopt as options
+from samba.netcmd import Command, Option
+from samba.netcmd import exception_to_command_error
+
+from samba.domain.models import Computer
+from samba.domain.models.exceptions import ModelError
+from samba.generate_csr import generate_csr
+
+
+class cmd_computer_generate_csr(Command):
+    """Generate a PEM‐encoded Certificate Signing Request for a computer."""
+
+    synopsis = "%prog <computername> <subject_name> <private_key_filename> 
<output_filename> [options]"
+


-- 
Samba Shared Repository

Reply via email to