diff --git a/tests/build-in-docker.sh b/tests/build-in-docker.sh new file mode 100755 index 0000000..d0b636e --- /dev/null +++ b/tests/build-in-docker.sh @@ -0,0 +1,7 @@ +#!/bin/bash -eu + +source tests/docker_env.sh +#run_in_docker rm -rf CMakeFiles +run_in_docker mkdir -p build_in_docker +run_in_docker -w "$PWD/build_in_docker" cmake -DENABLE_EMULATION=1 .. +run_in_docker -w "$PWD/build_in_docker" make -j ${NUM_PROC} diff --git a/tests/docker/bullseye/Dockerfile b/tests/docker/bullseye/Dockerfile new file mode 100644 index 0000000..65a92c5 --- /dev/null +++ b/tests/docker/bullseye/Dockerfile @@ -0,0 +1,31 @@ +FROM debian:bullseye + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt update && apt upgrade -y +RUN apt install -y apt-utils +RUN apt install -y libccid \ + libpcsclite-dev \ + git \ + autoconf \ + pkg-config \ + libtool \ + help2man \ + automake \ + gcc \ + make \ + build-essential \ + opensc \ + python3 \ + python3-pip \ + swig \ + cmake \ + libfuse-dev \ + && rm -rf /var/lib/apt/lists/* +RUN pip3 install pytest pycvc cryptography pyscard fido2 inputimeout +RUN git clone https://github.com/frankmorgner/vsmartcard.git +WORKDIR /vsmartcard/virtualsmartcard +RUN autoreconf --verbose --install +RUN ./configure --sysconfdir=/etc +RUN make && make install +WORKDIR / diff --git a/tests/docker/fido2/__init__.py b/tests/docker/fido2/__init__.py new file mode 100644 index 0000000..e172b9d --- /dev/null +++ b/tests/docker/fido2/__init__.py @@ -0,0 +1,269 @@ +# Copyright (c) 2020 Yubico AB +# All rights reserved. +# +# 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. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS 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 +# COPYRIGHT HOLDER 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. + +from __future__ import annotations + +from .base import HidDescriptor +from ..ctap import CtapDevice, CtapError, STATUS +from ..utils import LOG_LEVEL_TRAFFIC +from threading import Event +from enum import IntEnum, IntFlag, unique +from typing import Tuple, Optional, Callable, Iterator +import struct +import sys +import os +import logging + +logger = logging.getLogger(__name__) + + +if sys.platform.startswith("linux"): + from . import linux as backend +elif sys.platform.startswith("win32"): + from . import windows as backend +elif sys.platform.startswith("darwin"): + from . import macos as backend +elif sys.platform.startswith("freebsd"): + from . import freebsd as backend +elif sys.platform.startswith("openbsd"): + from . import openbsd as backend +else: + raise Exception("Unsupported platform") +from . import emulation as backend + + +list_descriptors = backend.list_descriptors +get_descriptor = backend.get_descriptor +open_connection = backend.open_connection + + +@unique +class CTAPHID(IntEnum): + PING = 0x01 + MSG = 0x03 + LOCK = 0x04 + INIT = 0x06 + WINK = 0x08 + CBOR = 0x10 + CANCEL = 0x11 + + ERROR = 0x3F + KEEPALIVE = 0x3B + + VENDOR_FIRST = 0x40 + + +@unique +class CAPABILITY(IntFlag): + WINK = 0x01 + LOCK = 0x02 # Not used + CBOR = 0x04 + NMSG = 0x08 + + def supported(self, flags: CAPABILITY) -> bool: + return bool(flags & self) + + +TYPE_INIT = 0x80 + + +class CtapHidDevice(CtapDevice): + """ + CtapDevice implementation using the HID transport. + + :cvar descriptor: Device descriptor. + """ + + def __init__(self, descriptor: HidDescriptor, connection): + self.descriptor = descriptor + self._packet_size = descriptor.report_size_out + self._connection = connection + + nonce = os.urandom(8) + self._channel_id = 0xFFFFFFFF + response = self.call(CTAPHID.INIT, nonce) + r_nonce, response = response[:8], response[8:] + if r_nonce != nonce: + raise Exception("Wrong nonce") + ( + self._channel_id, + self._u2fhid_version, + v1, + v2, + v3, + self._capabilities, + ) = struct.unpack_from(">IBBBBB", response) + self._device_version = (v1, v2, v3) + + def __repr__(self): + return f"CtapHidDevice({self.descriptor.path!r})" + + @property + def version(self) -> int: + """CTAP HID protocol version.""" + return self._u2fhid_version + + @property + def device_version(self) -> Tuple[int, int, int]: + """Device version number.""" + return self._device_version + + @property + def capabilities(self) -> int: + """Capabilities supported by the device.""" + return self._capabilities + + @property + def product_name(self) -> Optional[str]: + """Product name of device.""" + return self.descriptor.product_name + + @property + def serial_number(self) -> Optional[str]: + """Serial number of device.""" + return self.descriptor.serial_number + + def _send_cancel(self): + packet = struct.pack(">IB", self._channel_id, TYPE_INIT | CTAPHID.CANCEL).ljust( + self._packet_size, b"\0" + ) + logger.log(LOG_LEVEL_TRAFFIC, "SEND: %s", packet.hex()) + self._connection.write_packet(packet) + + def call( + self, + cmd: int, + data: bytes = b"", + event: Optional[Event] = None, + on_keepalive: Optional[Callable[[int], None]] = None, + ) -> bytes: + event = event or Event() + remaining = data + seq = 0 + + # Send request + header = struct.pack(">IBH", self._channel_id, TYPE_INIT | cmd, len(remaining)) + while remaining or seq == 0: + size = min(len(remaining), self._packet_size - len(header)) + body, remaining = remaining[:size], remaining[size:] + packet = header + body + logger.log(LOG_LEVEL_TRAFFIC, "SEND: %s", packet.hex()) + self._connection.write_packet(packet.ljust(self._packet_size, b"\0")) + header = struct.pack(">IB", self._channel_id, 0x7F & seq) + seq += 1 + + try: + # Read response + seq = 0 + response = b"" + last_ka = None + while True: + if event.is_set(): + # Cancel + logger.debug("Sending cancel...") + self._send_cancel() + + recv = self._connection.read_packet() + logger.log(LOG_LEVEL_TRAFFIC, "RECV: %s", recv.hex()) + + r_channel = struct.unpack_from(">I", recv)[0] + recv = recv[4:] + if r_channel != self._channel_id: + raise Exception("Wrong channel") + + if not response: # Initialization packet + r_cmd, r_len = struct.unpack_from(">BH", recv) + recv = recv[3:] + if r_cmd == TYPE_INIT | cmd: + pass # first data packet + elif r_cmd == TYPE_INIT | CTAPHID.KEEPALIVE: + ka_status = struct.unpack_from(">B", recv)[0] + logger.debug(f"Got keepalive status: {ka_status:02x}") + if on_keepalive and ka_status != last_ka: + try: + ka_status = STATUS(ka_status) + except ValueError: + pass # Unknown status value + last_ka = ka_status + on_keepalive(ka_status) + continue + elif r_cmd == TYPE_INIT | CTAPHID.ERROR: + raise CtapError(struct.unpack_from(">B", recv)[0]) + else: + raise CtapError(CtapError.ERR.INVALID_COMMAND) + else: # Continuation packet + r_seq = struct.unpack_from(">B", recv)[0] + recv = recv[1:] + if r_seq != seq: + raise Exception("Wrong sequence number") + seq += 1 + + response += recv + if len(response) >= r_len: + break + + return response[:r_len] + except KeyboardInterrupt: + logger.debug("Keyboard interrupt, cancelling...") + self._send_cancel() + + raise + + def wink(self) -> None: + """Causes the authenticator to blink.""" + self.call(CTAPHID.WINK) + + def ping(self, msg: bytes = b"Hello FIDO") -> bytes: + """Sends data to the authenticator, which echoes it back. + + :param msg: The data to send. + :return: The response from the authenticator. + """ + return self.call(CTAPHID.PING, msg) + + def lock(self, lock_time: int = 10) -> None: + """Locks the channel.""" + self.call(CTAPHID.LOCK, struct.pack(">B", lock_time)) + + def close(self) -> None: + if self._connection: + self._connection.close() + self._connection = None + + @classmethod + def list_devices(cls) -> Iterator[CtapHidDevice]: + for d in list_descriptors(): + yield cls(d, open_connection(d)) + + +def list_devices() -> Iterator[CtapHidDevice]: + return CtapHidDevice.list_devices() + + +def open_device(path) -> CtapHidDevice: + descriptor = get_descriptor(path) + return CtapHidDevice(descriptor, open_connection(descriptor)) diff --git a/tests/docker/fido2/emulation.py b/tests/docker/fido2/emulation.py new file mode 100644 index 0000000..5a58cbb --- /dev/null +++ b/tests/docker/fido2/emulation.py @@ -0,0 +1,79 @@ +# Original work Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Modified work Copyright 2020 Yubico AB. All Rights Reserved. +# This file, with modifications, is licensed under the above Apache License. + +from __future__ import annotations + +from .base import HidDescriptor, CtapHidConnection + +import socket +from typing import Set + +import logging +import sys + +HOST = '127.0.0.1' +PORT = 35962 + +# Don't typecheck this file on Windows +assert sys.platform != "win32" # nosec + +logger = logging.getLogger(__name__) + +class EmulationCtapHidConnection(CtapHidConnection): + def __init__(self, descriptor): + self.descriptor = descriptor + self.handle = descriptor.path + self.handle.connect((HOST, PORT)) + + def write_packet(self, packet): + if (self.handle.send(len(packet).to_bytes(2, 'big')) != 2): + raise OSError("write_packet sending size failed") + if (self.handle.send(packet) != len(packet)): + raise OSError("write_packet sending packet failed") + + def read_packet(self): + bts = self.handle.recv(2) + if (len(bts) != 2): + raise OSError("read_packet failed reading size") + size = int.from_bytes(bts, 'big') + data = self.handle.recv(size) + if (len(data) != size): + raise OSError("read_packet failed reading packet") + return data + + def close(self) -> None: + return self.handle.close() + + +def open_connection(descriptor): + return EmulationCtapHidConnection(descriptor) + + +def get_descriptor(_): + HOST = 'localhost' # The remote host + PORT = 35962 # The same port as used by the server + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + return HidDescriptor(s, 0x00, 0x00, 64, 64, "Pico-Fido", "AAAAAA") + +def list_descriptors(): + devices = [] + try: + devices.append(get_descriptor(None)) + except ValueError: + pass # Not a CTAP device, ignore. + + return devices diff --git a/tests/docker_env.sh b/tests/docker_env.sh new file mode 100644 index 0000000..384bc9c --- /dev/null +++ b/tests/docker_env.sh @@ -0,0 +1,107 @@ +#!/bin/bash -eu + +# Taken from Mbed-TLS project +# https://github.com/Mbed-TLS/mbedtls/blob/master/tests/scripts/docker_env.sh +# +# docker_env.sh +# +# Purpose +# ------- +# +# This is a helper script to enable running tests under a Docker container, +# thus making it easier to get set up as well as isolating test dependencies +# (which include legacy/insecure configurations of openssl and gnutls). +# +# WARNING: the Dockerfile used by this script is no longer maintained! See +# https://github.com/Mbed-TLS/mbedtls-test/blob/master/README.md#quick-start +# for the set of Docker images we use on the CI. +# +# Notes for users +# --------------- +# This script expects a Linux x86_64 system with a recent version of Docker +# installed and available for use, as well as http/https access. If a proxy +# server must be used, invoke this script with the usual environment variables +# (http_proxy and https_proxy) set appropriately. If an alternate Docker +# registry is needed, specify MBEDTLS_DOCKER_REGISTRY to point at the +# host name. +# +# +# Running this script directly will check for Docker availability and set up +# the Docker image. + +# Copyright The Mbed TLS Contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# default values, can be overridden by the environment +: ${MBEDTLS_DOCKER_GUEST:=bullseye} + + +DOCKER_IMAGE_TAG="pico-hsm-test:${MBEDTLS_DOCKER_GUEST}" + +# Make sure docker is available +if ! which docker > /dev/null; then + echo "Docker is required but doesn't seem to be installed. See https://www.docker.com/ to get started" + exit 1 +fi + +# Figure out if we need to 'sudo docker' +if groups | grep docker > /dev/null; then + DOCKER="docker" +else + echo "Using sudo to invoke docker since you're not a member of the docker group..." + DOCKER="docker" +fi + +# Figure out the number of processors available +if [ "$(uname)" == "Darwin" ]; then + NUM_PROC="$(sysctl -n hw.logicalcpu)" +else + NUM_PROC="$(nproc)" +fi + +# Build the Docker image +echo "Getting docker image up to date (this may take a few minutes)..." +${DOCKER} image build \ + -t ${DOCKER_IMAGE_TAG} \ + --cache-from=${DOCKER_IMAGE_TAG} \ + --network host \ + --build-arg MAKEFLAGS_PARALLEL="-j ${NUM_PROC}" \ + tests/docker/${MBEDTLS_DOCKER_GUEST} + +run_in_docker() +{ + ENV_ARGS="" + while [ "$1" == "-e" ]; do + ENV_ARGS="${ENV_ARGS} $1 $2" + shift 2 + done + + WORKDIR="${PWD}" + if [ "$1" == '-w' ]; then + WORKDIR="$2" + shift 2 + fi + + ${DOCKER} container run --rm \ + --cap-add ALL \ + --privileged \ + --volume $PWD:$PWD \ + --workdir ${WORKDIR} \ + -e MAKEFLAGS \ + ${ENV_ARGS} \ + ${DOCKER_IMAGE_TAG} \ + $@ +} diff --git a/tests/run-test-in-docker.sh b/tests/run-test-in-docker.sh new file mode 100755 index 0000000..ca3486e --- /dev/null +++ b/tests/run-test-in-docker.sh @@ -0,0 +1,5 @@ +#!/bin/bash -eu + +source tests/docker_env.sh +run_in_docker ./tests/start-up-and-test.sh + diff --git a/tests/start-up-and-test.sh b/tests/start-up-and-test.sh new file mode 100755 index 0000000..7457349 --- /dev/null +++ b/tests/start-up-and-test.sh @@ -0,0 +1,8 @@ +#!/bin/bash -eu + +/usr/sbin/pcscd & +sleep 2 +rm -f memory.flash +cp -R tests/Docker/fido2/* /usr/local/lib/python3.9/dist-packages/fido2/hid +./build_in_docker/pico_fido > /dev/null & +pytest tests