#!/usr/bin/python
# 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.)
'''

from __future__ import print_function

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 xrange(8)
    )

def generate_username():
    parts = ['adt.']
    parts += [rng.choice(string.lowercase) for i in xrange(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
