diff --git a/debian/control b/debian/control index 726a083f2..dddc4e14c 100644 --- a/debian/control +++ b/debian/control @@ -1,332 +1,336 @@ Source: vyos-1x Section: contrib/net Priority: extra Maintainer: VyOS Package Maintainers <maintainers@vyos.net> Build-Depends: debhelper (>= 9), dh-python, fakeroot, gcc, iproute2, libvyosconfig0 (>= 0.0.7), libzmq3-dev, python3 (>= 3.10), # For generating command definitions python3-lxml, python3-xmltodict, # For running tests python3-coverage, python3-netifaces, python3-nose, python3-jinja2, python3-psutil, python3-setuptools, python3-sphinx, quilt, whois Standards-Version: 3.9.6 Package: vyos-1x Architecture: amd64 arm64 Pre-Depends: libnss-tacplus [amd64], libpam-tacplus [amd64], libpam-radius-auth [amd64] Depends: ## Fundamentals ${python3:Depends} (>= 3.10), libvyosconfig0, vyatta-bash, vyatta-cfg, vyos-http-api-tools, vyos-utils, ## End of Fundamentals ## Python libraries used in multiple modules and scripts python3, python3-cryptography, python3-hurry.filesize, python3-inotify, python3-jinja2, python3-jmespath, python3-netaddr, python3-netifaces, python3-paramiko, python3-passlib, python3-psutil, python3-pyhumps, python3-pystache, python3-pyudev, python3-six, python3-tabulate, python3-voluptuous, python3-xmltodict, python3-zmq, ## End of Python libraries ## Basic System services and utilities sudo, systemd, bsdmainutils, openssl, curl, dbus, file, iproute2 (>= 6.0.0), linux-cpupower, # ipaddrcheck is widely used in IP value validators ipaddrcheck, ethtool, fdisk, lm-sensors, procps, netplug, sed, ssl-cert, tuned, beep, wide-dhcpv6-client, # Generic colorizer grc, ## End of System services and utilities ## For the installer # Image signature verification tool minisign, # Live filesystem tools squashfs-tools, fuse-overlayfs, ## End installer auditd, iputils-arping, isc-dhcp-client, # For "vpn pptp", "vpn l2tp", "vpn sstp", "service ipoe-server" accel-ppp, # End "vpn pptp", "vpn l2tp", "vpn sstp", "service ipoe-server" avahi-daemon, conntrack, conntrackd, ## Conf mode features # For "interfaces wireless" hostapd, hsflowd, iw, wireless-regdb, wpasupplicant (>= 0.6.7), # End "interfaces wireless" # For "interfaces wwan" modemmanager, usb-modeswitch, libqmi-utils, # End "interfaces wwan" # For "interfaces openvpn" openvpn, openvpn-auth-ldap, openvpn-auth-radius, openvpn-otp, libpam-google-authenticator, # End "interfaces openvpn" # For "interfaces wireguard" wireguard-tools, qrencode, # End "interfaces wireguard" # For "interfaces pppoe" pppoe, # End "interfaces pppoe" # For "interfaces sstpc" sstp-client, # End "interfaces sstpc" # For "protocols *" frr (>= 7.5), frr-pythontools, frr-rpki-rtrlib, frr-snmp, # End "protocols *" # For "protocols nhrp" (part of DMVPN) opennhrp, # End "protocols nhrp" # For "protocols igmp-proxy" igmpproxy, # End "protocols igmp-proxy" # For "pki" certbot, # End "pki" # For "service console-server" conserver-client, conserver-server, console-data, dropbear, # End "service console-server" # For "service aws glb" aws-gwlbtun, # For "service dns dynamic" ddclient (>= 3.11.1), # End "service dns dynamic" # # For "service ids" fastnetmon [amd64], # End "service ids" # # For "service ndp-proxy" ndppd, # End "service ndp-proxy" # For "service router-advert" radvd, # End "service route-advert" # For "high-availability reverse-proxy" haproxy, # End "high-availability reverse-proxy" # For "service dhcp-relay" isc-dhcp-relay, # For "service dhcp-server" kea, # End "service dhcp-server" # For "service lldp" lldpd, # End "service lldp" # For "service https" nginx-light, # End "service https" # For "service ssh" openssh-server, sshguard, # End "service ssh" # For "service salt-minion" salt-minion, # End "service salt-minion" # For "service snmp" snmp, snmpd, # End "service snmp" # For "service upnp" miniupnpd-nftables, # End "service upnp" # For "service webproxy" squid, squidclient, squidguard, # End "service webproxy" # For "service monitoring telegraf" telegraf (>= 1.20), # End "service monitoring telegraf" # For "service monitoring zabbix-agent" zabbix-agent2, # End "service monitoring zabbix-agent" # For "service tftp-server" tftpd-hpa, # End "service tftp-server" # For "service dns forwarding" pdns-recursor, # End "service dns forwarding" # For "service sla owamp" owamp-client, owamp-server, # End "service sla owamp" # For "service sla twamp" twamp-client, twamp-server, # End "service sla twamp" # For "service broadcast-relay" udp-broadcast-relay, # End "service broadcast-relay" # For "high-availability vrrp" keepalived (>=2.0.5), # End "high-availability-vrrp" # For "system task-scheduler" cron, # End "system task-scheduler" # For "system lcd" lcdproc, lcdproc-extra-drivers, # End "system lcd" # For "system config-management commit-archive" git, # End "system config-management commit-archive" # For firewall libndp-tools, libnetfilter-conntrack3, libnfnetlink0, nfct, nftables (>= 0.9.3), # For "vpn ipsec" strongswan (>= 5.9), strongswan-swanctl (>= 5.9), charon-systemd, libcharon-extra-plugins (>=5.9), libcharon-extauth-plugins (>=5.9), libstrongswan-extra-plugins (>=5.9), libstrongswan-standard-plugins (>=5.9), python3-vici (>= 5.7.2), # End "vpn ipsec" # For "nat64" jool, # End "nat64" # For "system ntp" chrony, # End "system ntp" # For "vpn openconnect" ocserv, # End "vpn openconnect" # For "system flow-accounting" pmacct (>= 1.6.0), # End "system flow-accounting" # For container podman, netavark, aardvark-dns, # iptables is only used for containers now, not the the firewall CLI iptables, # End container ## End Configuration mode ## Operational mode # Used for hypervisor model in "run show version" hvinfo, # For "run traceroute" traceroute, # For "run monitor traffic" tcpdump, # End "run monitor traffic" # For "show hardware dmi" dmidecode, # For "run show hardware storage smart" smartmontools, # For "run show hardware scsi" lsscsi, # For "run show hardware pci" pciutils, # For "show hardware usb" usbutils, # For "run show hardware storage nvme" nvme-cli, # For "run monitor bandwidth-test" iperf, iperf3, # End "run monitor bandwidth-test" # For "run wake-on-lan" etherwake, # For "run force ipv6-nd" ndisc6, # For "run monitor bandwidth" bmon, # End Operational mode +## TPM tools + cryptsetup, + tpm2-tools, +## End TPM tools ## Optional utilities easy-rsa, tcptraceroute, mtr-tiny, telnet, stunnel4, uidmap ## End optional utilities Description: VyOS configuration scripts and data VyOS configuration scripts, interface definitions, and everything Package: vyos-1x-vmware Architecture: amd64 Depends: vyos-1x, open-vm-tools Description: VyOS configuration scripts and data for VMware Adds configuration files required for VyOS running on VMware hosts. Package: vyos-1x-smoketest Architecture: all Depends: skopeo, snmp, vyos-1x Description: VyOS build sanity checking toolkit diff --git a/op-mode-definitions/crypt.xml.in b/op-mode-definitions/crypt.xml.in new file mode 100644 index 000000000..105592a1a --- /dev/null +++ b/op-mode-definitions/crypt.xml.in @@ -0,0 +1,28 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="encryption"> + <properties> + <help>Manage config encryption</help> + </properties> + <children> + <node name="disable"> + <properties> + <help>Disable config encryption using TPM or recovery key</help> + </properties> + <command>sudo ${vyos_libexec_dir}/vyos-config-encrypt.py --disable</command> + </node> + <node name="enable"> + <properties> + <help>Enable config encryption using TPM</help> + </properties> + <command>sudo ${vyos_libexec_dir}/vyos-config-encrypt.py --enable</command> + </node> + <node name="load"> + <properties> + <help>Load encrypted config volume using TPM or recovery key</help> + </properties> + <command>sudo ${vyos_libexec_dir}/vyos-config-encrypt.py --load</command> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/python/vyos/tpm.py b/python/vyos/tpm.py new file mode 100644 index 000000000..f120e10c4 --- /dev/null +++ b/python/vyos/tpm.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 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 +# published by the Free Software Foundation. +# +# 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 <http://www.gnu.org/licenses/>. + +import os +import tempfile + +from vyos.util import rc_cmd + +default_pcrs = ['0','2','4','7'] +tpm_handle = 0x81000000 + +def init_tpm(clear=False): + """ + Initialize TPM + """ + code, output = rc_cmd('tpm2_startup' + (' -c' if clear else '')) + if code != 0: + raise Exception('init_tpm: Failed to initialize TPM') + +def clear_tpm_key(): + """ + Clear existing key on TPM + """ + code, output = rc_cmd(f'tpm2_evictcontrol -C o -c {tpm_handle}') + if code != 0: + raise Exception('clear_tpm_key: Failed to clear TPM key') + +def read_tpm_key(index=0, pcrs=default_pcrs): + """ + Read existing key on TPM + """ + with tempfile.TemporaryDirectory() as tpm_dir: + pcr_str = ",".join(pcrs) + + tpm_key_file = os.path.join(tpm_dir, 'tpm_key.key') + code, output = rc_cmd(f'tpm2_unseal -c {tpm_handle + index} -p pcr:sha256:{pcr_str} -o {tpm_key_file}') + if code != 0: + raise Exception('read_tpm_key: Failed to read key from TPM') + + with open(tpm_key_file, 'rb') as f: + tpm_key = f.read() + + return tpm_key + +def write_tpm_key(key, index=0, pcrs=default_pcrs): + """ + Saves key to TPM + """ + with tempfile.TemporaryDirectory() as tpm_dir: + pcr_str = ",".join(pcrs) + + policy_file = os.path.join(tpm_dir, 'policy.digest') + code, output = rc_cmd(f'tpm2_createpolicy --policy-pcr -l sha256:{pcr_str} -L {policy_file}') + if code != 0: + raise Exception('write_tpm_key: Failed to create policy digest') + + primary_context_file = os.path.join(tpm_dir, 'primary.ctx') + code, output = rc_cmd(f'tpm2_createprimary -C e -g sha256 -G rsa -c {primary_context_file}') + if code != 0: + raise Exception('write_tpm_key: Failed to create primary key') + + key_file = os.path.join(tpm_dir, 'crypt.key') + with open(key_file, 'wb') as f: + f.write(key) + + public_obj = os.path.join(tpm_dir, 'obj.pub') + private_obj = os.path.join(tpm_dir, 'obj.key') + code, output = rc_cmd( + f'tpm2_create -g sha256 \ + -u {public_obj} -r {private_obj} \ + -C {primary_context_file} -L {policy_file} -i {key_file}') + + if code != 0: + raise Exception('write_tpm_key: Failed to create object') + + load_context_file = os.path.join(tpm_dir, 'load.ctx') + code, output = rc_cmd(f'tpm2_load -C {primary_context_file} -u {public_obj} -r {private_obj} -c {load_context_file}') + + if code != 0: + raise Exception('write_tpm_key: Failed to load object') + + code, output = rc_cmd(f'tpm2_evictcontrol -c {load_context_file} -C o {tpm_handle + index}') + + if code != 0: + raise Exception('write_tpm_key: Failed to write object to TPM') diff --git a/src/helpers/vyos-config-encrypt.py b/src/helpers/vyos-config-encrypt.py new file mode 100755 index 000000000..8f7359767 --- /dev/null +++ b/src/helpers/vyos-config-encrypt.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 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 +# published by the Free Software Foundation. +# +# 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 <http://www.gnu.org/licenses/>. + +import os +import re +import shutil +import sys + +from argparse import ArgumentParser +from cryptography.fernet import Fernet +from tempfile import NamedTemporaryFile +from tempfile import TemporaryDirectory + +from vyos.tpm import clear_tpm_key +from vyos.tpm import init_tpm +from vyos.tpm import read_tpm_key +from vyos.tpm import write_tpm_key +from vyos.util import ask_input +from vyos.util import ask_yes_no +from vyos.util import cmd + +persistpath_cmd = '/opt/vyatta/sbin/vyos-persistpath' +mount_paths = ['/config', '/opt/vyatta/etc/config'] +dm_device = '/dev/mapper/vyos_config' + +def is_opened(): + return os.path.exists(dm_device) + +def get_current_image(): + with open('/proc/cmdline', 'r') as f: + args = f.read().split(" ") + for arg in args: + if 'vyos-union' in arg: + k, v = arg.split("=") + path_split = v.split("/") + return path_split[-1] + return None + +def load_config(key): + if not key: + return + + persist_path = cmd(persistpath_cmd).strip() + image_name = get_current_image() + image_path = os.path.join(persist_path, 'luks', image_name) + + if not os.path.exists(image_path): + raise Exception("Encrypted config volume doesn't exist") + + if is_opened(): + print('Encrypted config volume is already mounted') + return + + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(key) + key_file = f.name + + cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') + + for path in mount_paths: + cmd(f'mount /dev/mapper/vyos_config {path}') + cmd(f'chgrp -R vyattacfg {path}') + + os.unlink(key_file) + + return True + +def encrypt_config(key, recovery_key): + if is_opened(): + raise Exception('An encrypted config volume is already mapped') + + # Clear and write key to TPM + try: + clear_tpm_key() + except: + pass + write_tpm_key(key) + + persist_path = cmd(persistpath_cmd).strip() + size = ask_input('Enter size of encrypted config partition (MB): ', numeric_only=True, default=512) + + luks_folder = os.path.join(persist_path, 'luks') + + if not os.path.isdir(luks_folder): + os.mkdir(luks_folder) + + image_name = get_current_image() + image_path = os.path.join(luks_folder, image_name) + + # Create file for encrypted config + cmd(f'fallocate -l {size}M {image_path}') + + # Write TPM key for slot #1 + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(key) + key_file = f.name + + # Format and add main key to volume + cmd(f'cryptsetup -q luksFormat {image_path} {key_file}') + + if recovery_key: + # Write recovery key for slot 2 + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(recovery_key) + recovery_key_file = f.name + + cmd(f'cryptsetup -q luksAddKey {image_path} {recovery_key_file} --key-file={key_file}') + + # Open encrypted volume and format with ext4 + cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') + cmd('mkfs.ext4 /dev/mapper/vyos_config') + + with TemporaryDirectory() as d: + cmd(f'mount /dev/mapper/vyos_config {d}') + + # Move /config to encrypted volume + shutil.copytree('/config', d, copy_function=shutil.move, dirs_exist_ok=True) + + cmd(f'umount {d}') + + os.unlink(key_file) + + if recovery_key: + os.unlink(recovery_key_file) + + for path in mount_paths: + cmd(f'mount /dev/mapper/vyos_config {path}') + cmd(f'chgrp vyattacfg {path}') + + return True + +def decrypt_config(key): + if not key: + return + + persist_path = cmd(persistpath_cmd).strip() + image_name = get_current_image() + image_path = os.path.join(persist_path, 'luks', image_name) + + if not os.path.exists(image_path): + raise Exception("Encrypted config volume doesn't exist") + + key_file = None + + if not is_opened(): + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(key) + key_file = f.name + + cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') + + # unmount encrypted volume mount points + for path in mount_paths: + if os.path.ismount(path): + cmd(f'umount {path}') + + # If /config is populated, move to /config.old + if len(os.listdir('/config')) > 0: + print('Moving existing /config folder to /config.old') + shutil.move('/config', '/config.old') + + # Temporarily mount encrypted volume and migrate files to /config on rootfs + with TemporaryDirectory() as d: + cmd(f'mount /dev/mapper/vyos_config {d}') + + # Move encrypted volume to /config + shutil.copytree(d, '/config', copy_function=shutil.move, dirs_exist_ok=True) + cmd(f'chgrp -R vyattacfg /config') + + cmd(f'umount {d}') + + # Close encrypted volume + cmd('cryptsetup -q close vyos_config') + + # Remove encrypted volume image file and key + if key_file: + os.unlink(key_file) + os.unlink(image_path) + + try: + clear_tpm_key() + except: + pass + + return True + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Must specify action.") + sys.exit(1) + + parser = ArgumentParser(description='Config encryption') + parser.add_argument('--disable', help='Disable encryption', action="store_true") + parser.add_argument('--enable', help='Enable encryption', action="store_true") + parser.add_argument('--load', help='Load encrypted config volume', action="store_true") + args = parser.parse_args() + + tpm_exists = os.path.exists('/sys/class/tpm/tpm0') + + key = None + recovery_key = None + need_recovery = False + + question_key_str = 'recovery key' if tpm_exists else 'key' + + if tpm_exists: + if args.enable: + key = Fernet.generate_key() + elif args.disable or args.load: + try: + key = read_tpm_key() + need_recovery = False + except: + print('Failed to read key from TPM, recovery key required') + need_recovery = True + else: + need_recovery = True + + if args.enable and not tpm_exists: + print('WARNING: VyOS will boot into a default config when encrypted without a TPM') + print('You will need to manually login with default credentials and use "encryption load"') + print('to mount the encrypted volume and use "load /config/config.boot"') + + if not ask_yes_no('Are you sure you want to proceed?'): + sys.exit(0) + + if need_recovery or (args.enable and not ask_yes_no(f'Automatically generate a {question_key_str}?', default=True)): + while True: + recovery_key = ask_input(f'Enter {question_key_str}:', default=None).encode() + + if len(recovery_key) >= 32: + break + + print('Invalid key - must be at least 32 characters, try again.') + else: + recovery_key = Fernet.generate_key() + + try: + if args.disable: + decrypt_config(key or recovery_key) + + print('Encrypted config volume has been disabled') + print('Contents have been migrated to /config on rootfs') + elif args.load: + load_config(key or recovery_key) + + print('Encrypted config volume has been mounted') + print('Use "load /config/config.boot" to load configuration') + elif args.enable and tpm_exists: + encrypt_config(key, recovery_key) + + print('Encrypted config volume has been enabled with TPM') + print('Backup the recovery key in a safe place!') + print('Recovery key: ' + recovery_key.decode()) + elif args.enable: + encrypt_config(recovery_key) + + print('Encrypted config volume has been enabled without TPM') + print('Backup the key in a safe place!') + print('Key: ' + recovery_key.decode()) + except Exception as e: + word = 'decrypt' if args.disable or args.load else 'encrypt' + print(f'Failed to {word} config: {e}')