From 2256f8b3846366a58d6e8adb9685d455d6e1ed96 Mon Sep 17 00:00:00 2001
From: cronekorkn <git@ckn.li>
Date: Tue, 1 Aug 2023 18:08:54 +0200
Subject: [PATCH 1/3] wip

---
 bundles/bind/README.md   |  29 +++++++++
 bundles/bind/items.py    |  34 +++++++++++
 bundles/bind/metadata.py |  18 +++++-
 libs/dnssec.py           | 129 +++++++++++++++++++++++++++++++++++++++
 libs/rsa.py              |   9 ++-
 nodes/netcup.mails.py    |  28 ++++-----
 6 files changed, 227 insertions(+), 20 deletions(-)
 create mode 100644 bundles/bind/README.md
 create mode 100755 libs/dnssec.py

diff --git a/bundles/bind/README.md b/bundles/bind/README.md
new file mode 100644
index 0000000..bd78bcc
--- /dev/null
+++ b/bundles/bind/README.md
@@ -0,0 +1,29 @@
+## DNSSEC
+
+https://wiki.debian.org/DNSSEC%20Howto%20for%20BIND%209.9+#The_signing_part
+https://blog.apnic.net/2021/11/02/dnssec-provisioning-automation-with-cds-cdnskey-in-the-real-world/
+https://gist.github.com/wido/4c6288b2f5ba6d16fce37dca3fc2cb4a
+
+```python
+import dns.dnssec
+algorithm = dns.dnssec.RSASHA256
+```
+
+```python
+import cryptography
+pk = cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key(key_size=2048, public_exponent=65537)
+```
+
+## Nomenclature
+
+### parent
+
+DNSKEY:
+  the public key
+
+DS
+
+### sub
+
+ZSK/KSK:
+  https://www.cloudflare.com/de-de/dns/dnssec/how-dnssec-works/
diff --git a/bundles/bind/items.py b/bundles/bind/items.py
index b85c92e..8853747 100644
--- a/bundles/bind/items.py
+++ b/bundles/bind/items.py
@@ -132,6 +132,40 @@ for view_name, view_conf in master_node.metadata.get('bind/views').items():
         }
 
 
+for zone, conf in master_node.metadata.get('bind/zones').items():
+    directories[f"/var/lib/bind/{view_name}"] = {
+        'owner': 'bind',
+        'group': 'bind',
+        'purge': True,
+        'needed_by': [
+            'svc_systemd:bind9',
+        ],
+        'triggers': [
+            'svc_systemd:bind9:restart',
+        ],
+    }
+
+    for zone_name, zone_conf in view_conf['zones'].items():
+        files[f"/var/lib/bind/{view_name}/{zone_name}"] = {
+            'source': 'db',
+            'content_type': 'mako',
+            'unless': f"test -f /var/lib/bind/{view_name}/{zone_name}" if zone_conf.get('allow_update', False) else 'false',
+            'context': {
+                'serial': datetime.now().strftime('%Y%m%d%H'),
+                'records': zone_conf['records'],
+                'hostname': node.metadata.get('bind/hostname'),
+                'type': node.metadata.get('bind/type'),
+            },
+            'owner': 'bind',
+            'group': 'bind',
+            'needed_by': [
+                'svc_systemd:bind9',
+            ],
+            'triggers': [
+                'svc_systemd:bind9:restart',
+            ],
+        }
+
 svc_systemd['bind9'] = {}
 
 actions['named-checkconf'] = {
diff --git a/bundles/bind/metadata.py b/bundles/bind/metadata.py
index 56155fc..89f9d15 100644
--- a/bundles/bind/metadata.py
+++ b/bundles/bind/metadata.py
@@ -39,7 +39,7 @@ defaults = {
                 'zones': {},
             },
         },
-        'zones': set(),
+        'zones': {},
     },
     'nftables': {
         'input': {
@@ -61,6 +61,22 @@ defaults = {
 }
 
 
+@metadata_reactor.provides(
+    'bind/zones',
+)
+def dnssec(metadata):
+    return {
+        'bind': {
+            'zones': {
+                zone: {
+                    'dnssec': repo.libs.dnssec.generate_dnssec_for_zone(zone, node),
+                }
+                    for zone in metadata.get('bind/zones')
+            },
+        },
+    }
+
+
 @metadata_reactor.provides(
     'bind/type',
     'bind/master_ip',
diff --git a/libs/dnssec.py b/libs/dnssec.py
new file mode 100755
index 0000000..5cc4553
--- /dev/null
+++ b/libs/dnssec.py
@@ -0,0 +1,129 @@
+# https://medium.com/iocscan/how-dnssec-works-9c652257be0
+# https://de.wikipedia.org/wiki/RRSIG_Resource_Record
+# https://metebalci.com/blog/a-minimum-complete-tutorial-of-dnssec/
+# https://bind9.readthedocs.io/en/latest/dnssec-guide.html
+
+from sys import argv
+from os.path import realpath, dirname
+from bundlewrap.repo import Repository
+from base64 import b64decode, urlsafe_b64encode
+from cryptography.utils import int_to_bytes
+from cryptography.hazmat.primitives import serialization as crypto_serialization
+from struct import pack, unpack
+from hashlib import sha1, sha256
+
+
+def long_to_base64(n):
+    return urlsafe_b64encode(int_to_bytes(n, None)).decode()
+
+flags = 256
+protocol = 3
+algorithm = 8
+algorithm_name = 'RSASHA256'
+
+# ZSK/KSK DNSKEY
+#
+# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateNumbers
+# https://crypto.stackexchange.com/a/21104
+
+def generate_signing_key_pair(zone, salt, repo):
+    privkey = repo.libs.rsa.generate_deterministic_rsa_private_key(
+        b64decode(str(repo.vault.random_bytes_as_base64_for(f'dnssec {salt} ' + zone)))
+    )
+    privkey_pem = privkey.private_bytes(
+        crypto_serialization.Encoding.PEM,
+        crypto_serialization.PrivateFormat.PKCS8,
+        crypto_serialization.NoEncryption()
+    ).decode()
+
+    public_exponent = privkey.private_numbers().public_numbers.e
+    modulo = privkey.private_numbers().public_numbers.n
+    private_exponent = privkey.private_numbers().d
+    prime1 = privkey.private_numbers().p
+    prime2 = privkey.private_numbers().q
+    exponent1 = privkey.private_numbers().dmp1
+    exponent2 = privkey.private_numbers().dmq1
+    coefficient = privkey.private_numbers().iqmp
+
+    dnskey = ''.join(privkey.public_key().public_bytes(
+        crypto_serialization.Encoding.PEM,
+        crypto_serialization.PublicFormat.SubjectPublicKeyInfo
+    ).decode().split('\n')[1:-2])
+
+    return {
+        'dnskey': dnskey,
+        'dnskey_record': f'{zone}. IN DNSKEY {flags} {protocol} {algorithm} {dnskey}',
+        'privkey': privkey_pem,
+        'privkey_file': {
+            'Private-key-format': 'v1.3',
+            'Algorithm': f'{algorithm} ({algorithm_name})',
+            'Modulus': long_to_base64(modulo),
+            'PublicExponent': long_to_base64(public_exponent),
+            'PrivateExponent': long_to_base64(private_exponent),
+            'Prime1': long_to_base64(prime1),
+            'Prime2': long_to_base64(prime2),
+            'Exponent1': long_to_base64(exponent1),
+            'Exponent2': long_to_base64(exponent2),
+            'Coefficient': long_to_base64(coefficient),
+            'Created': 20230428110109,
+            'Publish': 20230428110109,
+            'Activate': 20230428110109,
+        },
+    }
+
+
+# DS
+#
+# https://gist.github.com/wido/4c6288b2f5ba6d16fce37dca3fc2cb4a#file-dnskey_to_dsrecord-py-L40
+
+def _calc_ds(zone, flags, protocol, algorithm, dnskey):
+    if zone.endswith('.') is False:
+        zone += '.'
+
+    signature = bytes()
+    for i in zone.split('.'):
+        signature += pack('B', len(i)) + i.encode()
+
+    signature += pack('!HBB', int(flags), int(protocol), int(algorithm))
+    signature += b64decode(dnskey)
+
+    return {
+        'sha1':    sha1(signature).hexdigest().upper(),
+        'sha256':  sha256(signature).hexdigest().upper(),
+    }
+
+def _calc_keyid(flags, protocol, algorithm, dnskey):
+    st = pack('!HBB', int(flags), int(protocol), int(algorithm))
+    st += b64decode(dnskey)
+
+    cnt = 0
+    for idx in range(len(st)):
+        s = unpack('B', st[idx:idx+1])[0]
+        if (idx % 2) == 0:
+            cnt += s << 8
+        else:
+            cnt += s
+
+    return ((cnt & 0xFFFF) + (cnt >> 16)) & 0xFFFF
+
+def dnskey_to_ds(zone, flags, protocol, algorithm, dnskey):
+    keyid = _calc_keyid(flags, protocol, algorithm, dnskey)
+    ds = _calc_ds(zone, flags, protocol, algorithm, dnskey)
+
+    return[
+        f"{zone}. IN DS {str(keyid)} {str(algorithm)} 1 {ds['sha1'].lower()}",
+        f"{zone}. IN DS {str(keyid)} {str(algorithm)} 2 {ds['sha256'].lower()}",
+    ]
+
+# Result
+
+def generate_dnssec_for_zone(zone, node):
+    zsk_data = generate_signing_key_pair(zone, salt='zsk', repo=node.repo)
+    ksk_data = generate_signing_key_pair(zone, salt='ksk', repo=node.repo)
+    ds_records = dnskey_to_ds(zone, flags, protocol, algorithm, ksk_data['dnskey'])
+
+    return {
+        'zsk_data': zsk_data,
+        'ksk_data': ksk_data,
+        'ds_records': ds_records,
+    }
diff --git a/libs/rsa.py b/libs/rsa.py
index e2666fb..a16e065 100644
--- a/libs/rsa.py
+++ b/libs/rsa.py
@@ -1,7 +1,6 @@
 # https://stackoverflow.com/a/18266970
 
 from Crypto.PublicKey import RSA
-from Crypto.Hash import HMAC
 from struct import pack
 from hashlib import sha3_512
 from cryptography.hazmat.primitives.serialization import load_der_private_key
@@ -23,12 +22,12 @@ class PRNG(object):
 
 
 @cache_to_disk(30)
-def _generate_deterministic_rsa_private_key(secret_bytes):
-    return RSA.generate(2048, randfunc=PRNG(secret_bytes)).export_key('DER')
+def _generate_deterministic_rsa_private_key(secret_bytes, key_size):
+    return RSA.generate(key_size, randfunc=PRNG(secret_bytes)).export_key('DER')
 
 @cache
-def generate_deterministic_rsa_private_key(secret_bytes):
+def generate_deterministic_rsa_private_key(secret_bytes, key_size=2048):
     return load_der_private_key(
-        _generate_deterministic_rsa_private_key(secret_bytes),
+        _generate_deterministic_rsa_private_key(secret_bytes, key_size),
         password=None,
     )
diff --git a/nodes/netcup.mails.py b/nodes/netcup.mails.py
index 70c5351..acfa3b5 100644
--- a/nodes/netcup.mails.py
+++ b/nodes/netcup.mails.py
@@ -39,20 +39,20 @@
             'hostname': 'resolver.name',
             'acme_zone': 'acme.sublimity.de',
             'zones': {
-                'sublimity.de',
-                'freibrief.net',
-                'nadenau.net',
-                'naeder.net',
-                'wettengl.net',
-                'wingl.de',
-                'woodpipe.de',
-                'ckn.li',
-                'islamicstate.eu',
-                'hausamsilberberg.de',
-                'wiegand.tel',
-                'lonercrew.io',
-                'left4.me',
-                'elimu-kwanza.de',
+                'sublimity.de': {},
+                'freibrief.net': {},
+                'nadenau.net': {},
+                'naeder.net': {},
+                'wettengl.net': {},
+                'wingl.de': {},
+                'woodpipe.de': {},
+                'ckn.li': {},
+                'islamicstate.eu': {},
+                'hausamsilberberg.de': {},
+                'wiegand.tel': {},
+                'lonercrew.io': {},
+                'left4.me': {},
+                'elimu-kwanza.de': {},
             },
         },
         'dns': {
-- 
2.39.5


From 5b0b2e77ce4c3c19f2a5a0541dbe050e5cbfbce0 Mon Sep 17 00:00:00 2001
From: cronekorkn <git@ckn.li>
Date: Tue, 1 Aug 2023 18:58:37 +0200
Subject: [PATCH 2/3] wip

---
 bundles/bind/files/dnssec.key     |  2 +
 bundles/bind/files/dnssec.private | 13 +++++++
 bundles/bind/items.py             | 65 +++++++++++++++++++++----------
 libs/dnssec.py                    | 17 +++-----
 4 files changed, 65 insertions(+), 32 deletions(-)
 create mode 100644 bundles/bind/files/dnssec.key
 create mode 100644 bundles/bind/files/dnssec.private

diff --git a/bundles/bind/files/dnssec.key b/bundles/bind/files/dnssec.key
new file mode 100644
index 0000000..82841e9
--- /dev/null
+++ b/bundles/bind/files/dnssec.key
@@ -0,0 +1,2 @@
+; ${type} ${key_id}
+${record}
diff --git a/bundles/bind/files/dnssec.private b/bundles/bind/files/dnssec.private
new file mode 100644
index 0000000..3a46b3f
--- /dev/null
+++ b/bundles/bind/files/dnssec.private
@@ -0,0 +1,13 @@
+Private-key-format: ${data['Private-key-format']}
+Algorithm: ${data['Algorithm']}
+Modulus: ${data['Modulus']}
+PublicExponent: ${data['PublicExponent']}
+PrivateExponent: ${data['PrivateExponent']}
+Prime1: ${data['Prime1']}
+Prime2: ${data['Prime2']}
+Exponent1: ${data['Exponent1']}
+Exponent2: ${data['Exponent2']}
+Coefficient: ${data['Coefficient']}
+Created: ${data['Created']}
+Publish: ${data['Publish']}
+Activate: ${data['Activate']}
diff --git a/bundles/bind/items.py b/bundles/bind/items.py
index 8853747..a006bf9 100644
--- a/bundles/bind/items.py
+++ b/bundles/bind/items.py
@@ -23,6 +23,20 @@ directories[f'/var/lib/bind'] = {
     ],
 }
 
+directories[f'/var/cache/bind/keys'] = {
+    'group': 'bind',
+    'purge': True,
+    'needs': [
+        'pkg_apt:bind9',
+    ],
+    'needed_by': [
+        'svc_systemd:bind9',
+    ],
+    'triggers': [
+        'svc_systemd:bind9:restart',
+    ],
+}
+
 files['/etc/default/bind9'] = {
     'source': 'defaults',
     'needed_by': [
@@ -132,32 +146,20 @@ for view_name, view_conf in master_node.metadata.get('bind/views').items():
         }
 
 
-for zone, conf in master_node.metadata.get('bind/zones').items():
-    directories[f"/var/lib/bind/{view_name}"] = {
-        'owner': 'bind',
-        'group': 'bind',
-        'purge': True,
-        'needed_by': [
-            'svc_systemd:bind9',
-        ],
-        'triggers': [
-            'svc_systemd:bind9:restart',
-        ],
-    }
+for zone_name, zone_conf in master_node.metadata.get('bind/zones').items():
+    for sk in ('zsk_data', 'ksk_data'):
+        data = zone_conf['dnssec'][sk]
 
-    for zone_name, zone_conf in view_conf['zones'].items():
-        files[f"/var/lib/bind/{view_name}/{zone_name}"] = {
-            'source': 'db',
+        files[f"/var/cache/bind/keys/K{zone_name}.+008+{data['key_id']}.private"] = {
             'content_type': 'mako',
-            'unless': f"test -f /var/lib/bind/{view_name}/{zone_name}" if zone_conf.get('allow_update', False) else 'false',
+            'source': 'dnssec.private',
             'context': {
-                'serial': datetime.now().strftime('%Y%m%d%H'),
-                'records': zone_conf['records'],
-                'hostname': node.metadata.get('bind/hostname'),
-                'type': node.metadata.get('bind/type'),
+                'data': data['privkey_file'],
             },
-            'owner': 'bind',
             'group': 'bind',
+            'needs': [
+                'pkg_apt:bind9',
+            ],
             'needed_by': [
                 'svc_systemd:bind9',
             ],
@@ -166,6 +168,27 @@ for zone, conf in master_node.metadata.get('bind/zones').items():
             ],
         }
 
+        files[f"/var/cache/bind/keys/K{zone_name}.+008+{data['key_id']}.key"] = {
+            'content_type': 'mako',
+            'source': 'dnssec.key',
+            'context': {
+                'type': sk,
+                'key_id': data['key_id'],
+                'record': data['dnskey_record'],
+            },
+            'group': 'bind',
+            'needs': [
+                'pkg_apt:bind9',
+            ],
+            'needed_by': [
+                'svc_systemd:bind9',
+            ],
+            'triggers': [
+                'svc_systemd:bind9:restart',
+            ],
+        }
+
+
 svc_systemd['bind9'] = {}
 
 actions['named-checkconf'] = {
diff --git a/libs/dnssec.py b/libs/dnssec.py
index 5cc4553..1e43136 100755
--- a/libs/dnssec.py
+++ b/libs/dnssec.py
@@ -30,11 +30,6 @@ def generate_signing_key_pair(zone, salt, repo):
     privkey = repo.libs.rsa.generate_deterministic_rsa_private_key(
         b64decode(str(repo.vault.random_bytes_as_base64_for(f'dnssec {salt} ' + zone)))
     )
-    privkey_pem = privkey.private_bytes(
-        crypto_serialization.Encoding.PEM,
-        crypto_serialization.PrivateFormat.PKCS8,
-        crypto_serialization.NoEncryption()
-    ).decode()
 
     public_exponent = privkey.private_numbers().public_numbers.e
     modulo = privkey.private_numbers().public_numbers.n
@@ -44,6 +39,7 @@ def generate_signing_key_pair(zone, salt, repo):
     exponent1 = privkey.private_numbers().dmp1
     exponent2 = privkey.private_numbers().dmq1
     coefficient = privkey.private_numbers().iqmp
+    flags = 256 if salt == 'zsk' else 257
 
     dnskey = ''.join(privkey.public_key().public_bytes(
         crypto_serialization.Encoding.PEM,
@@ -53,7 +49,7 @@ def generate_signing_key_pair(zone, salt, repo):
     return {
         'dnskey': dnskey,
         'dnskey_record': f'{zone}. IN DNSKEY {flags} {protocol} {algorithm} {dnskey}',
-        'privkey': privkey_pem,
+        'key_id': _calc_keyid(flags, protocol, algorithm, dnskey),
         'privkey_file': {
             'Private-key-format': 'v1.3',
             'Algorithm': f'{algorithm} ({algorithm_name})',
@@ -106,13 +102,12 @@ def _calc_keyid(flags, protocol, algorithm, dnskey):
 
     return ((cnt & 0xFFFF) + (cnt >> 16)) & 0xFFFF
 
-def dnskey_to_ds(zone, flags, protocol, algorithm, dnskey):
-    keyid = _calc_keyid(flags, protocol, algorithm, dnskey)
+def dnskey_to_ds(zone, flags, protocol, algorithm, dnskey, key_id):
     ds = _calc_ds(zone, flags, protocol, algorithm, dnskey)
 
     return[
-        f"{zone}. IN DS {str(keyid)} {str(algorithm)} 1 {ds['sha1'].lower()}",
-        f"{zone}. IN DS {str(keyid)} {str(algorithm)} 2 {ds['sha256'].lower()}",
+        f"{zone}. IN DS {str(key_id)} {str(algorithm)} 1 {ds['sha1'].lower()}",
+        f"{zone}. IN DS {str(key_id)} {str(algorithm)} 2 {ds['sha256'].lower()}",
     ]
 
 # Result
@@ -120,7 +115,7 @@ def dnskey_to_ds(zone, flags, protocol, algorithm, dnskey):
 def generate_dnssec_for_zone(zone, node):
     zsk_data = generate_signing_key_pair(zone, salt='zsk', repo=node.repo)
     ksk_data = generate_signing_key_pair(zone, salt='ksk', repo=node.repo)
-    ds_records = dnskey_to_ds(zone, flags, protocol, algorithm, ksk_data['dnskey'])
+    ds_records = dnskey_to_ds(zone, flags, protocol, algorithm, ksk_data['dnskey'], ksk_data['key_id'])
 
     return {
         'zsk_data': zsk_data,
-- 
2.39.5


From 4567b66c606678fcad7c8682c5e93e8d17c56b0c Mon Sep 17 00:00:00 2001
From: cronekorkn <git@ckn.li>
Date: Tue, 1 Aug 2023 19:03:08 +0200
Subject: [PATCH 3/3] wip

---
 libs/dnssec.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/libs/dnssec.py b/libs/dnssec.py
index 1e43136..990e66b 100755
--- a/libs/dnssec.py
+++ b/libs/dnssec.py
@@ -46,9 +46,13 @@ def generate_signing_key_pair(zone, salt, repo):
         crypto_serialization.PublicFormat.SubjectPublicKeyInfo
     ).decode().split('\n')[1:-2])
 
+    dnskey_chunks = ' '.join(
+        dnskey[i:i+56] for i in range(0, len(dnskey), 56)
+    )
+
     return {
         'dnskey': dnskey,
-        'dnskey_record': f'{zone}. IN DNSKEY {flags} {protocol} {algorithm} {dnskey}',
+        'dnskey_record': f'{zone}. IN DNSKEY {flags} {protocol} {algorithm} {dnskey_chunks}',
         'key_id': _calc_keyid(flags, protocol, algorithm, dnskey),
         'privkey_file': {
             'Private-key-format': 'v1.3',
-- 
2.39.5