#!/usr/bin/python3

# This file is part of Cockpit.
#
# Copyright (C) 2015 Red Hat, Inc.
#
# Cockpit is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
#
# Cockpit 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.

import parent
from storagelib import *
from testlib import *


class TestStorageLuks(StorageCase):

    # LUKS uses memory hard PBKDF, 1 GiB is not enough; see https://bugzilla.redhat.com/show_bug.cgi?id=1881829
    provision = {
        "0": {"memory_mb": 1536}
    }

    def testLuks(self):
        self.allow_journal_messages("Device is not initialized.*", ".*could not be opened.")
        m = self.machine
        b = self.browser

        mount_point_secret = "/run/secret"

        self.login_and_go("/storage")

        # Add a disk and partition it
        m.add_disk("50M", serial="MYDISK")
        b.wait_in_text("#drives", "MYDISK")
        b.click('tr:contains("MYDISK")')
        b.wait_visible("#storage-detail")
        b.click('button:contains(Create partition table)')
        self.dialog({"type": "gpt"})
        self.content_row_wait_in_col(1, 0, "Free space")

        assert m.execute("grep -v ^# /etc/crypttab || true").strip() == ""

        # Format it with luks
        self.content_row_action(1, "Create partition")
        self.dialog_wait_open()
        self.dialog_set_val("size", 17)
        self.dialog_set_val("type", "ext4")
        self.dialog_set_val("crypto.on", True)
        self.dialog_set_val("name", "ENCRYPTED")
        self.dialog_set_val("passphrase", "vainu-reku-toma-rolle-kaja")
        self.dialog_set_val("passphrase2", "vainu-reku-toma-rolle-kaja")
        self.dialog_set_val("crypto_options.store_passphrase", True)
        self.dialog_set_val("crypto_options.extra", "crypto,options")
        self.dialog_set_val("mount_point", mount_point_secret)
        self.dialog_set_val("mount_options.auto", False)
        self.dialog_apply()
        self.dialog_wait_close()
        self.content_row_wait_in_col(1, 1, "Encrypted data")
        self.content_row_wait_in_col(2, 1, "ext4 file system")

        self.wait_in_storaged_configuration(mount_point_secret)
        self.wait_in_storaged_configuration("crypto,options")
        # HACK: Put /etc/crypttab in the journal, in order to debug updating issues
        assert m.execute("cat /etc/crypttab | logger -s 2>&1 | grep 'UUID='") != ""
        assert m.execute("grep %s /etc/fstab" % mount_point_secret) != ""
        assert m.execute("cat /etc/luks-keys/*") == "vainu-reku-toma-rolle-kaja"

        # Lock it
        self.content_dropdown_action(1, "Lock")
        b.wait_not_in_text("#detail-content", "ext4 file system")

        # It should be listed on the Overview. Click it to come back
        # here.
        b.go("#/")
        b.mousedown("#locked-cryptos tr:contains(MYDISK)")
        b.wait_visible("#storage-detail")

        # Unlock, this uses the stored passphrase
        self.content_head_action(1, "Unlock")
        self.content_row_wait_in_col(2, 1, "ext4 file system")

        # Change options.  We keep trying until the stack has synched
        # up with crypttab and we see the old options.
        self.dialog_with_retry(trigger=lambda: self.content_tab_info_action(1, 2, "Options"),
                               expect={"crypto_options.extra": "crypto,options"},
                               values={"crypto_options.extra": "weird,options"})

        assert m.execute("grep 'weird,options' /etc/crypttab") != ""
        self.wait_in_storaged_configuration("weird,options")

        # Change passphrase
        edit_button = self.content_tab_info_label(1, 2, "Stored passphrase") + " + dd button"
        self.dialog_with_retry(trigger=lambda: b.click(edit_button),
                               expect={"passphrase": "vainu-reku-toma-rolle-kaja"},
                               values={"passphrase": "wrong-passphrase"})

        assert m.execute("cat /etc/luks-keys/*") == "wrong-passphrase"

        # Remove passphrase
        edit_button = self.content_tab_info_label(1, 2, "Stored passphrase") + " + dd button"
        self.dialog_with_retry(trigger=lambda: b.click(edit_button),
                               expect={"passphrase": "wrong-passphrase"},
                               values={"passphrase": ""})
        self.wait_in_storaged_configuration("'passphrase-path': <b''>")

        # Lock it
        self.content_dropdown_action(1, "Lock")
        b.wait_not_in_text("#detail-content", "ext4 file system")

        # Unlock, this asks for a passphrase
        self.content_head_action(1, "Unlock")
        self.dialog({"passphrase": "vainu-reku-toma-rolle-kaja"})
        self.content_row_wait_in_col(2, 1, "ext4 file system")

        # Delete the partition.
        self.content_dropdown_action(1, "Delete")
        self.confirm()
        self.content_row_wait_in_col(1, 0, "Free space")
        b.wait_not_in_text("#detail-content", "ext4 file system")
        # luksmeta-monitor-hack.py races with the partition deletion
        self.allow_journal_messages('Unknown device .*: No such file or directory')

        assert m.execute("grep -v ^# /etc/crypttab || true").strip() == ""
        assert m.execute("grep %s /etc/fstab || true" % mount_point_secret) == ""

        # luksmeta-monitor-hack.py might leave a udevadm process
        # behind, so let's check that the session goes away cleanly
        # after a logout.

        b.logout()
        wait(lambda: m.execute("(loginctl list-users | grep admin) || true") == "")

    @skipImage("No clevis/tang", "debian-stable", "debian-testing", "ubuntu-2004", "ubuntu-stable")
    def testClevisTang(self):
        m = self.machine
        b = self.browser

        # We generate the keys and cache explicitly before starting
        # the socket.  This allows us to control their names, which
        # helps later on.

        m.execute("rm -rf /var/db/tang/*")
        m.execute("jose jwk gen -i '{\"alg\":\"ES512\"}' >/var/db/tang/sig1.jwk")
        m.execute("jose jwk gen -i '{\"alg\":\"ECMR\"}' -o /var/db/tang/enc1.jwk")

        m.execute("systemctl start tangd.socket")

        self.login_and_go("/storage")

        # Add a disk and format it with luks
        m.add_disk("50M", serial="MYDISK")
        b.wait_in_text("#drives", "MYDISK")
        b.click('tr:contains("MYDISK")')
        b.wait_visible("#storage-detail")

        self.content_head_action(1, "Format")
        self.dialog({"type": "ext4",
                     "crypto.on": True,
                     "name": "ENCRYPTED",
                     "mount_point": "/foo",
                     "mount_options.auto": False,
                     "passphrase": "vainu-reku-toma-rolle-kaja",
                     "passphrase2": "vainu-reku-toma-rolle-kaja"})
        self.content_row_wait_in_col(1, 1, "Encrypted data")
        self.content_row_wait_in_col(2, 1, "ext4 file system")

        # Lock the disk
        #
        self.content_dropdown_action(1, "Lock")
        b.wait_not_in_text("#detail-content", "ext4 file system")

        self.content_tab_wait_in_info(1, 1, "Options", "(none)")
        tab = self.content_tab_expand(1, 1)
        panel = tab + " .pf-c-card:contains(Keys) "
        b.wait_visible(panel)
        b.wait_in_text(panel + ".table tr:nth-child(1)", "Passphrase")

        # Add a key
        #
        b.click(panel + ".fa-plus")
        self.dialog_wait_open()
        self.dialog_wait_apply_enabled()
        self.dialog_set_val("type", "tang")
        self.dialog_set_val("tang_url", "127.0.0.1")
        self.dialog_set_val("passphrase", "vainu-reku-toma-rolle-kaja")
        self.dialog_apply()
        b.wait_in_text("#dialog", "Make sure the key hash from the Tang server matches:")
        b.wait_in_text("#dialog", m.execute("jose jwk thp -i /var/db/tang/sig1.jwk").strip())
        self.dialog_apply()
        self.dialog_wait_close()
        b.wait_visible(panel + ".table tr:nth-child(2)")
        b.wait_in_text(panel + ".table tr:nth-child(2)", "127.0.0.1")

        # Unlock it.  This should succeed without prompting.
        #
        self.content_head_action(1, "Unlock")
        self.content_row_wait_in_col(2, 1, "ext4 file system")

        # Edit the key, without providing an existing passphrase
        #
        b.click(panel + ".table tr:nth-child(2) .pficon-edit")
        self.dialog_wait_open()
        self.dialog_wait_apply_enabled()
        self.dialog_wait_val("tang_url", "127.0.0.1")
        self.dialog_set_val("tang_url", "http://127.0.0.1/")
        self.dialog_apply()
        b.wait_in_text("#dialog", "Make sure the key hash from the Tang server matches:")
        b.wait_in_text("#dialog", m.execute("jose jwk thp -i /var/db/tang/sig1.jwk").strip())
        self.dialog_apply()
        self.dialog_wait_close()
        b.wait_in_text(panel + ".table tr:nth-child(2)", "http://127.0.0.1/")

        # Remove key on client
        #
        b.click(panel + ".table tr:nth-child(2) .fa-minus")
        self.confirm()
        b.wait_not_present(panel + ".table tr:nth-child(2)")

    @skipImage("No clevis/tang", "debian-stable", "debian-testing", "ubuntu-2004", "ubuntu-stable")
    def testSlots(self):
        self.allow_journal_messages("Device is not initialized.*", ".*could not be opened.")
        m = self.machine
        b = self.browser

        luks_2 = m.image in ["rhel-8-3", "rhel-8-3-distropkg", "rhel-8-4", "rhel-8-4-distropkg", "centos-8-stream"]
        error_base = "Error unlocking /dev/sda: Failed to activate device: "
        error_messages = [error_base + "Operation not permitted",
                          error_base + "Incorrect passphrase."]

        self.login_and_go("/storage")

        m.add_disk("50M", serial="MYDISK")
        b.wait_in_text("#drives", "MYDISK")
        b.click('tr:contains("MYDISK")')
        b.wait_visible("#storage-detail")
        # create volume and passphrase
        self.content_head_action(1, "Format")
        self.dialog({"type": "ext4",
                     "crypto.on": True,
                     "name": "ENCRYPTED",
                     "mount_point": "/foo",
                     "mount_options.auto": False,
                     "passphrase": "vainu-reku-toma-rolle-kaja",
                     "passphrase2": "vainu-reku-toma-rolle-kaja"})
        self.content_row_wait_in_col(1, 1, "Encrypted data")
        self.content_row_wait_in_col(2, 1, "ext4 file system")
        self.content_tab_wait_in_info(1, 1, "Options", "(none)")
        # add one more passphrase
        tab = self.content_tab_expand(1, 1)
        panel = tab + " .pf-c-card:contains(Keys) "
        b.wait_visible(panel)
        b.click(panel + ".fa-plus")
        self.dialog_wait_open()
        self.dialog_wait_apply_enabled()
        self.dialog_set_val("type", "luks-passphrase")
        self.dialog_set_val("new_passphrase", "vainu-reku-toma-rolle-kaja-1")
        self.dialog_set_val("new_passphrase2", "vainu-reku-toma-rolle-kaja-1")
        self.dialog_set_val("passphrase", "vainu-reku-toma-rolle-kaja")
        self.dialog_apply()
        self.dialog_wait_close()
        # unlock with first passphrase
        self.content_dropdown_action(1, "Lock")
        b.wait_not_in_text("#detail-content", "ext4 file system")
        self.content_head_action(1, "Unlock")
        self.dialog({"passphrase": "vainu-reku-toma-rolle-kaja"})
        self.content_row_wait_in_col(2, 1, "ext4 file system")
        # unlock with second passphrase
        self.content_dropdown_action(1, "Lock")
        b.wait_not_in_text("#detail-content", "ext4 file system")
        self.content_head_action(1, "Unlock")
        self.dialog({"passphrase": "vainu-reku-toma-rolle-kaja-1"})
        self.content_row_wait_in_col(2, 1, "ext4 file system")
        # delete second key slot by providing passphrase
        b.click(panel + "tr:nth-child(2) .fa-minus")
        self.dialog_wait_open()
        b.set_input_text(self.dialog_field("passphrase") + " input[type=password]", "vainu-reku-toma-rolle-kaja-1")
        self.dialog_apply()
        self.dialog_wait_close()
        # check that it is not possible to unlock with deleted passphrase
        self.content_dropdown_action(1, "Lock")
        b.wait_not_in_text("#detail-content", "ext4 file system")
        self.content_head_action(1, "Unlock")
        self.dialog_wait_open()
        self.dialog_set_val("passphrase", "vainu-reku-toma-rolle-kaja-1")
        self.dialog_apply()
        b.wait_visible(".pf-c-alert")
        self.assertIn(b.text("h4.pf-c-alert__title:not(span)").split("Danger alert:", 1).pop(), error_messages)
        self.dialog_cancel()

        # add more passphrases, seven exactly, to reach the limit of eight for LUKSv1
        for i in range(1, 8):
            b.click(panel + ".fa-plus")
            self.dialog_wait_open()
            self.dialog_wait_apply_enabled()
            self.dialog_set_val("type", "luks-passphrase")
            self.dialog_set_val("new_passphrase", "vainu-reku-toma-rolle-kaja-%s" % i)
            self.dialog_set_val("new_passphrase2", "vainu-reku-toma-rolle-kaja-%s" % i)
            self.dialog_set_val("passphrase", "vainu-reku-toma-rolle-kaja")
            self.dialog_apply()
            self.dialog_wait_close()

        if not luks_2:
            # check if add button is inactive
            b.wait_visible(panel + ".pf-c-card__header button:disabled")
            # check if edit button is inactive
            slots_table = tab + " .pf-c-card table tr:first-child"
            b.wait_visible(slots_table + " button:disabled")

        # remove one slot
        slots_table = tab + " .pf-c-card table "
        b.wait_visible(slots_table + "tbody tr:last-child .fa-minus")
        b.click(slots_table + "tbody tr:last-child .fa-minus")
        b.set_input_text(self.dialog_field("passphrase") + " input[type=password]", "vainu-reku-toma-rolle-kaja-7")
        self.dialog_apply()
        self.dialog_wait_close()
        # check if buttons have become enabled after removing last slot
        b.wait_not_present(slots_table + ":disabled")
        b.wait_not_present(panel + ":disabled")
        # remove slot 0, with the original passphrase
        b.click(slots_table + "tbody tr:nth-child(1) .fa-minus")
        b.click(self.dialog_field("passphrase") + " label:contains(Force remove passphrase in key slot 0)")
        self.dialog_apply()
        self.dialog_wait_close()
        # check that it is not possible to unlock with deleted passphrase
        self.content_head_action(1, "Unlock")
        self.dialog_wait_open()
        self.dialog_set_val("passphrase", "vainu-reku-toma-rolle-kaja")
        self.dialog_apply()
        b.wait_visible(".pf-c-alert")
        self.assertIn(b.text("h4.pf-c-alert__title:not(span)").split("Danger alert:", 1).pop(), error_messages)
        self.dialog_cancel()
        # change one of the passphrases
        b.wait_visible(slots_table + "tbody tr:last-child .pficon-edit")
        b.click(slots_table + "tbody tr:last-child .pficon-edit")
        self.dialog_wait_open()
        self.dialog_wait_apply_enabled()
        self.dialog_set_val("old_passphrase", "vainu-reku-toma-rolle-kaja-6")
        self.dialog_set_val("new_passphrase", "vainu-reku-toma-rolle-kaja-8")
        self.dialog_set_val("new_passphrase2", "vainu-reku-toma-rolle-kaja-8")
        self.dialog_apply()
        self.dialog_wait_close()
        # unlock volume with the newly created passphrase
        self.content_head_action(1, "Unlock")
        self.dialog({"passphrase": "vainu-reku-toma-rolle-kaja-8"})
        self.content_row_wait_in_col(2, 1, "ext4 file system")


    def testNoFsys(self):
        m = self.machine
        b = self.browser

        self.login_and_go("/storage")

        # Add a disk and format it with luks, but without filesystem
        m.add_disk("50M", serial="MYDISK")
        b.wait_in_text("#drives", "MYDISK")
        b.click('tr:contains("MYDISK")')
        b.wait_visible("#storage-detail")

        self.content_head_action(1, "Format")
        self.dialog({"type": "empty",
                     "crypto.on": True,
                     "passphrase": "vainu-reku-toma-rolle-kaja",
                     "passphrase2": "vainu-reku-toma-rolle-kaja"})
        self.content_row_wait_in_col(1, 1, "Encrypted data")
        self.content_row_wait_in_col(2, 1, "Unrecognized data")

    def testNoauto(self):
        m = self.machine
        b = self.browser

        self.login_and_go("/storage")

        # Add a disk and format it with luks and a filesystem, but without "Unlock at boot"

        m.add_disk("50M", serial="MYDISK")
        b.wait_in_text("#drives", "MYDISK")
        b.click('tr:contains("MYDISK")')
        b.wait_visible("#storage-detail")

        self.content_head_action(1, "Format")
        self.dialog({"type": "ext4",
                     "crypto.on": True,
                     "crypto_options.auto": False,
                     "passphrase": "vainu-reku-toma-rolle-kaja",
                     "passphrase2": "vainu-reku-toma-rolle-kaja",
                     "mount_options.auto": True,
                     "mount_point": "/run/foo"})
        self.content_row_wait_in_col(1, 1, "Encrypted data")
        self.content_tab_wait_in_info(1, 1, "Options", "noauto")
        self.content_row_wait_in_col(2, 1, "ext4 file system")

        # The filesystem should be mounted but have the "noauto" option
        self.wait_mounted(2,1)
        self.assertIn("noauto", m.execute("grep /run/foo /etc/fstab || true"))

        # Unmounting should keep the noauto option, as always
        self.content_dropdown_action(2, "Unmount")
        self.content_tab_wait_in_info(2, 1, "Mount point", "The filesystem is not mounted")
        self.assertIn("noauto", m.execute("grep /run/foo /etc/fstab || true"))

        # Mounting should also keep the "noauto", but it should not show up in the extra options
        self.content_head_action(2, "Mount")
        self.dialog_check({ "mount_options.extra": False })
        self.dialog_apply()
        self.wait_mounted(2,1)
        self.assertIn("noauto", m.execute("grep /run/foo /etc/fstab || true"))

        # As should updating the mount information
        self.content_tab_info_action(2, 1, "Mount point", wrapped=True)
        self.dialog_check({ "mount_options.extra": False })
        self.dialog_apply()
        self.assertIn("noauto", m.execute("grep /run/foo /etc/fstab || true"))

        # Removing "noauto" externally should show a warning
        m.execute("sed -i -e 's/noauto//' /etc/fstab")
        fsys_tab = self.content_tab_expand(2, 1)
        b.wait_in_text(fsys_tab, "The filesystem is configured to be automatically mounted on boot but its encryption container will not be unlocked at that time.")
        b.click(fsys_tab + " button:contains(Do not mount automatically)")
        b.wait_not_present(fsys_tab + " button:contains(Do not mount automatically)")

if __name__ == '__main__':
    test_main()
