From 8c78ef0879f22ffd4a5f7fdb175e9109b46e9d7b Mon Sep 17 00:00:00 2001
From: Christian Breunig <christian@breunig.cc>
Date: Sun, 11 Feb 2024 21:49:25 +0100
Subject: pki: T6034: add OpenSSH key support

set pki openssh rpki private key ...
set pki openssh rpki public key ...
set pki openssh rpki public type 'ssh-rsa'
---
 .../include/pki/openssh-key.xml.i                  | 14 ++++++++
 interface-definitions/pki.xml.in                   | 39 ++++++++++++++++++++++
 python/vyos/pki.py                                 | 31 ++++++++++++++---
 src/conf_mode/pki.py                               | 32 ++++++++++++++++++
 4 files changed, 112 insertions(+), 4 deletions(-)
 create mode 100644 interface-definitions/include/pki/openssh-key.xml.i

diff --git a/interface-definitions/include/pki/openssh-key.xml.i b/interface-definitions/include/pki/openssh-key.xml.i
new file mode 100644
index 000000000..8f005d077
--- /dev/null
+++ b/interface-definitions/include/pki/openssh-key.xml.i
@@ -0,0 +1,14 @@
+<!-- include start from pki/openssh-key.xml.i -->
+<leafNode name="key">
+  <properties>
+    <help>OpenSSH key in PKI configuration</help>
+    <completionHelp>
+      <path>pki openssh</path>
+    </completionHelp>
+    <valueHelp>
+      <format>txt</format>
+      <description>Name of OpenSSH key in PKI configuration</description>
+    </valueHelp>
+  </properties>
+</leafNode>
+<!-- include end -->
diff --git a/interface-definitions/pki.xml.in b/interface-definitions/pki.xml.in
index 617bdd584..7a0b073b4 100644
--- a/interface-definitions/pki.xml.in
+++ b/interface-definitions/pki.xml.in
@@ -157,6 +157,45 @@
           </node>
         </children>
       </tagNode>
+      <tagNode name="openssh">
+        <properties>
+          <help>OpenSSH public and private keys</help>
+        </properties>
+        <children>
+          <node name="public">
+            <properties>
+              <help>Public key</help>
+            </properties>
+            <children>
+              #include <include/pki/cli-public-key-base64.xml.i>
+              <leafNode name="type">
+                <properties>
+                  <help>SSH public key type</help>
+                  <completionHelp>
+                    <list>ssh-rsa</list>
+                  </completionHelp>
+                  <valueHelp>
+                    <format>ssh-rsa</format>
+                    <description>Key pair based on RSA algorithm</description>
+                  </valueHelp>
+                  <constraint>
+                    <regex>(ssh-rsa)</regex>
+                  </constraint>
+                </properties>
+              </leafNode>
+            </children>
+          </node>
+          <node name="private">
+            <properties>
+              <help>Private key</help>
+            </properties>
+            <children>
+              #include <include/pki/cli-private-key-base64.xml.i>
+              #include <include/pki/password-protected.xml.i>
+            </children>
+          </node>
+        </children>
+      </tagNode>
       <tagNode name="openssh">
         <properties>
           <help>OpenSSH public and private keys</help>
diff --git a/python/vyos/pki.py b/python/vyos/pki.py
index 792e24b76..02dece471 100644
--- a/python/vyos/pki.py
+++ b/python/vyos/pki.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# Copyright (C) 2023-2024 VyOS maintainers and contributors
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 2 or later as
@@ -20,7 +20,9 @@ import ipaddress
 from cryptography import x509
 from cryptography.exceptions import InvalidSignature
 from cryptography.x509.extensions import ExtensionNotFound
-from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, ExtensionOID
+from cryptography.x509.oid import NameOID
+from cryptography.x509.oid import ExtendedKeyUsageOID
+from cryptography.x509.oid import ExtensionOID
 from cryptography.hazmat.primitives import hashes
 from cryptography.hazmat.primitives import serialization
 from cryptography.hazmat.primitives.asymmetric import dh
@@ -45,6 +47,8 @@ DH_BEGIN='-----BEGIN DH PARAMETERS-----\n'
 DH_END='\n-----END DH PARAMETERS-----'
 OVPN_BEGIN = '-----BEGIN OpenVPN Static key V{0}-----\n'
 OVPN_END = '\n-----END OpenVPN Static key V{0}-----'
+OPENSSH_KEY_BEGIN='-----BEGIN OPENSSH PRIVATE KEY-----\n'
+OPENSSH_KEY_END='\n-----END OPENSSH PRIVATE KEY-----'
 
 # Print functions
 
@@ -229,6 +233,12 @@ def wrap_public_key(raw_data):
 def wrap_private_key(raw_data, passphrase=None):
     return (KEY_ENC_BEGIN if passphrase else KEY_BEGIN) + raw_data + (KEY_ENC_END if passphrase else KEY_END)
 
+def wrap_openssh_public_key(raw_data, type):
+    return f'{type} {raw_data}'
+
+def wrap_openssh_private_key(raw_data):
+    return OPENSSH_KEY_BEGIN + raw_data +  OPENSSH_KEY_END
+
 def wrap_certificate_request(raw_data):
     return CSR_BEGIN + raw_data + CSR_END
 
@@ -245,7 +255,6 @@ def wrap_openvpn_key(raw_data, version='1'):
     return OVPN_BEGIN.format(version) + raw_data + OVPN_END.format(version)
 
 # Load functions
-
 def load_public_key(raw_data, wrap_tags=True):
     if wrap_tags:
         raw_data = wrap_public_key(raw_data)
@@ -267,6 +276,21 @@ def load_private_key(raw_data, passphrase=None, wrap_tags=True):
     except ValueError:
         return False
 
+def load_openssh_public_key(raw_data, type):
+    try:
+        return serialization.load_ssh_public_key(bytes(f'{type} {raw_data}', 'utf-8'))
+    except ValueError:
+        return False
+
+def load_openssh_private_key(raw_data, passphrase=None, wrap_tags=True):
+    if wrap_tags:
+        raw_data = wrap_openssh_private_key(raw_data)
+
+    try:
+        return serialization.load_ssh_private_key(bytes(raw_data, 'utf-8'), password=passphrase)
+    except ValueError:
+        return False
+
 def load_certificate_request(raw_data, wrap_tags=True):
     if wrap_tags:
         raw_data = wrap_certificate_request(raw_data)
@@ -429,4 +453,3 @@ def sort_ca_chain(ca_names, pki_node):
 
     from functools import cmp_to_key
     return sorted(ca_names, key=cmp_to_key(lambda cert1, cert2: ca_cmp(cert1, cert2, pki_node)))
-
diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py
index 4be40e99e..2d076e42d 100755
--- a/src/conf_mode/pki.py
+++ b/src/conf_mode/pki.py
@@ -29,6 +29,8 @@ from vyos.defaults import directories
 from vyos.pki import is_ca_certificate
 from vyos.pki import load_certificate
 from vyos.pki import load_public_key
+from vyos.pki import load_openssh_public_key
+from vyos.pki import load_openssh_private_key
 from vyos.pki import load_private_key
 from vyos.pki import load_crl
 from vyos.pki import load_dh_parameters
@@ -150,6 +152,11 @@ def get_config(config=None):
         if 'changed' not in pki: pki.update({'changed':{}})
         pki['changed'].update({'key_pair' : tmp})
 
+    tmp = node_changed(conf, base + ['openssh'], recursive=True)
+    if tmp:
+        if 'changed' not in pki: pki.update({'changed':{}})
+        pki['changed'].update({'openssh' : tmp})
+
     tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], recursive=True)
     if tmp:
         if 'changed' not in pki: pki.update({'changed':{}})
@@ -241,6 +248,17 @@ def is_valid_private_key(raw_data, protected=False):
         return True
     return load_private_key(raw_data, passphrase=None, wrap_tags=True)
 
+def is_valid_openssh_public_key(raw_data, type):
+    # If it loads correctly we're good, or return False
+    return load_openssh_public_key(raw_data, type)
+
+def is_valid_openssh_private_key(raw_data, protected=False):
+    # If it loads correctly we're good, or return False
+    # With encrypted private keys, we always return true as we cannot ask for password to verify
+    if protected:
+        return True
+    return load_openssh_private_key(raw_data, passphrase=None, wrap_tags=True)
+
 def is_valid_crl(raw_data):
     # If it loads correctly we're good, or return False
     return load_crl(raw_data, wrap_tags=True)
@@ -322,6 +340,20 @@ def verify(pki):
                 if not is_valid_private_key(private['key'], protected):
                     raise ConfigError(f'Invalid private key on key-pair "{name}"')
 
+    if 'openssh' in pki:
+        for name, key_conf in pki['openssh'].items():
+            if 'public' in key_conf and 'key' in key_conf['public']:
+                if 'type' not in key_conf['public']:
+                    raise ConfigError(f'Must define OpenSSH public key type for "{name}"')
+                if not is_valid_openssh_public_key(key_conf['public']['key'], key_conf['public']['type']):
+                    raise ConfigError(f'Invalid OpenSSH public key "{name}"')
+
+            if 'private' in key_conf and 'key' in key_conf['private']:
+                private = key_conf['private']
+                protected = 'password_protected' in private
+                if not is_valid_openssh_private_key(private['key'], protected):
+                    raise ConfigError(f'Invalid OpenSSH private key "{name}"')
+
     if 'x509' in pki:
         if 'default' in pki['x509']:
             default_values = pki['x509']['default']
-- 
cgit v1.2.3