#!/usr/bin/python3
# encoding=UTF-8

# Copyright © 2014 Jakub Wilk <jwilk@debian.org>

# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. The names of the authors may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS “AS IS” AND ANY
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

'''
WARNING
-------
This test breaks stuff. Do NOT run it without sufficiently powerful
virtualisation.

The test does the following changes to the system, which may negatively affect
its security:

* Change root password. The new password is not guaranteed to be strong.

* Add a new (unprivileged) user.

* Add pam_alreadyloggedin.so, enabled for /dev/pts/*, to /etc/pam.d/login.

* Remove pam_securetty.so from /etc/pam.d/login.

An attempt is made to later undo all the above changes, but with no guarantee
of success.

Additionally:

* The test may leave traces of its actions in the syslog.

* The test may leave stale utmp entries.

(The list of side effects of running this test is not necessarily complete.)
'''

import contextlib
import errno
import os
import pty
import pwd
import random
import signal
import string
import subprocess as ipc
import sys

rng = random.SystemRandom()

class Timeout(RuntimeError):
    default = 5

def sigalrm_handler(signum, frame):
    raise Timeout

def msg_begin(s, *args, **kwargs):
    print(s.format(*args, **kwargs), '...', end=' ')

def msg_end(s='ok', *args, **kwargs):
    print(s.format(*args, **kwargs))

msg = msg_end

@contextlib.contextmanager
def c_pam():
    path = '/etc/pam.d/login'
    msg_begin('create pam.d/login backup')
    with open(path, 'r') as file:
        contents = file.readlines()
    with open(path + '.adt-bak', 'w') as file:
        for line in contents:
            file.write(line)
    msg_end()
    msg_begin('create new pam.d/login')
    okmsg = 'ok'
    with open(path + '.adt-new', 'w') as file:
        file.write('auth sufficient pam_alreadyloggedin.so no_root restrict_tty=/dev/pts/* restrict_loggedin_tty=/dev/pts/*\n')
        for line in contents:
            if line.endswith('pam_securetty.so\n'):
                okmsg += ' (pam_securetty.so skipped)'
                continue
            file.write(line)
    msg_end(okmsg)
    try:
        msg_begin('plant new pam.d/login')
        os.rename(path + '.adt-new', path)
        msg_end()
        yield
    finally:
        msg_begin('restore pam.d/login')
        os.rename(path + '.adt-bak', path)
        msg_end()

def generate_password():
    return ''.join(
        rng.choice(
            string.letters + string.digits
        ) for i in range(8)
    )

def generate_username():
    parts = ['adt.']
    parts += [rng.choice(string.lowercase) for i in range(8)]
    return ''.join(parts)

def change_password(login, password, already_encrypted=False):
    msg_begin('change password for {0}', login)
    cmdline = ['chpasswd']
    if already_encrypted:
        cmdline += ['--encrypted']
    child = ipc.Popen(cmdline, stdin=ipc.PIPE)
    child.stdin.write('{0}:{1}\n'.format(login, password))
    child.stdin.close()
    retcode = child.wait()
    if retcode:
        raise ipc.CalledProcessError(retcode, cmdline)
    msg_end()

def add_user(login, password):
    msg_begin('add user {0}', login)
    ipc.check_call(
        ['useradd', '-M', login]
    )
    uid = pwd.getpwnam(login).pw_uid
    msg_end('uid={0}', uid)
    change_password(login, password)

def delete_user(login):
    msg_begin('delete user {0}', login)
    ipc.check_call(['userdel', '-f', login])
    msg_end()

@contextlib.contextmanager
def c_user():
    login = generate_username()
    password = generate_password()
    add_user(login, password)
    try:
        yield (login, password)
    finally:
        delete_user(login)

def get_encrypted_password(login):
    msg_begin('get password for {0}', login)
    line = ipc.check_output(
        ['getent', 'shadow', login]
    )
    result = line.split(':')[1]
    msg_end()
    return result

@contextlib.contextmanager
def c_root_password():
    old_encrypted_password = get_encrypted_password('root')
    new_password = generate_password()
    try:
        change_password('root', new_password)
        yield new_password
    finally:
        change_password('root', old_encrypted_password, already_encrypted=True)

@contextlib.contextmanager
def c_timeout(timeout=Timeout.default):
    signal.alarm(timeout)
    try:
        yield
    finally:
        signal.alarm(0)

def pty_expect(fd, expected, timeout=Timeout.default):
    with c_timeout(timeout=timeout):
        s = ''
        while True:
            try:
                chunk = os.read(fd, 1024)
            except OSError as exc:
                if exc.errno == errno.EIO and not expected:
                    return
                else:
                    raise
            for subchunk in chunk.splitlines():
                print('| {fd} | {chunk}'.format(fd=fd, chunk=subchunk))
            s += chunk
            if expected in s:
                break

@contextlib.contextmanager
def c_fork_login(timeout=Timeout.default):
    msg_begin('fork pty')
    pid, fd = pty.fork()
    if not pid:
        os.execlp('login', 'login')
    try:
        msg_end('pid={0}, fd={1}', pid, fd)
        yield pid, fd
    finally:
        with c_timeout(timeout):
            msg_begin('waitpid({0})', pid)
            os.waitpid(pid, 0)
            msg_end()

def main():
    msg_begin('getenv(ADTTMP)')
    if os.getenv('ADTTMP'):
        msg_end()
    else:
        msg_end('unset')
        msg(__doc__)
        sys.exit(1)
    msg_begin('getuid()')
    if os.getuid() == 0:
        msg_end('root')
    else:
        msg_end('regular user')
        sys.exit(1)
    utmp_path = '/var/run/utmp'
    msg_begin(utmp_path)
    os.stat(utmp_path)
    msg_end()
    msg_begin('reset umask')
    os.umask(0o022)
    msg_end()
    msg_begin('setup SIGALRM handler')
    signal.signal(signal.SIGALRM, sigalrm_handler)
    msg_end()
    with c_pam():
        with c_user() as (login, password):
            test_user(login, password)
        with c_root_password() as root_password:
            test_root(root_password)

def test_user(login, password):
    with c_fork_login() as (tty1_pid, tty1):
        # tty1
        pty_expect(tty1, 'login: ')
        os.write(tty1, login + '\n')
        pty_expect(tty1, 'Password: ')
        os.write(tty1, password + '\n')
        pty_expect(tty1, '$ ')
        # tty2
        with c_fork_login() as (tty2_pid, tty2):
            pty_expect(tty2, 'login: ')
            os.write(tty2, login + '\n')
            pty_expect(tty2, '$ ')
            os.write(tty2, 'exit\n')
            pty_expect(tty2, '')
        # tty1
        os.write(tty1, 'exit\n')
        pty_expect(tty1, '')
    os.close(tty1)
    os.close(tty2)
    with c_fork_login() as (tty1_pid, tty1):
        pty_expect(tty1, 'login: ')
        os.write(tty1, login + '\n')
        pty_expect(tty1, 'Password: ')
        os.write(tty1, password + '\n')
        pty_expect(tty1, '$ ')
        os.write(tty1, 'exit\n')
    os.close(tty1)

def test_root(password):
    with c_fork_login() as (tty1_pid, tty1):
        # tty1
        pty_expect(tty1, 'login: ')
        os.write(tty1, 'root\n')
        pty_expect(tty1, 'Password: ')
        os.write(tty1, password + '\n')
        pty_expect(tty1, '# ')
        # tty2
        with c_fork_login() as (tty2_pid, tty2):
            pty_expect(tty2, 'login: ')
            os.write(tty2, 'root\n')
            pty_expect(tty2, 'Password: ')
            os.write(tty2, password + '\n')
            pty_expect(tty2, '# ')
            os.write(tty2, 'exit\n')
            pty_expect(tty2, '')
        # tty1
        os.write(tty1, 'exit\n')
        pty_expect(tty1, '')
    os.close(tty1)
    os.close(tty2)

if __name__ == '__main__':
    main()

# vim:ts=4 sw=4 et
