#!/usr/bin/env python

# Copyright New York University and the in-toto contributors
# SPDX-License-Identifier: Apache-2.0

"""
<Program Name>
  in_toto_sign.py

<Author>
  Sachit Malik <i.sachitmalik@gmail.com>
  Lukas Puehringer <lukas.puehringer@nyu.edu>

<Started>
  June 13, 2017

<Copyright>
  See LICENSE for licensing information.

<Purpose>
  Provides command line interface to sign in-toto link or layout metadata
  or to verify its signatures.

"""
import sys
import argparse
import logging

from in_toto import exceptions
from in_toto.models.link import FILENAME_FORMAT
from in_toto.models.metadata import Metablock
from in_toto.common_args import (GPG_HOME_ARGS, GPG_HOME_KWARGS, VERBOSE_ARGS,
    VERBOSE_KWARGS, QUIET_ARGS, QUIET_KWARGS, title_case_action_groups,
    sort_action_groups)
from in_toto import (
    __version__, SUPPORTED_KEY_TYPES, KEY_TYPE_RSA, KEY_TYPE_ED25519)

import securesystemslib.formats
from securesystemslib import interface
from securesystemslib.gpg import functions as gpg_interface


# Command line interfaces should use in_toto base logger (c.f. in_toto.log)
LOG = logging.getLogger("in_toto")


def _sign_and_dump_metadata(metadata, args):
  """
  <Purpose>
    Internal method to sign link or layout metadata and dump it to disk.

  <Arguments>
    metadata:
            Metablock object (contains Link or Layout object)
    args:
            see argparser

  <Exceptions>
    SystemExit(0) if signing is successful
    SystemExit(2) if any exception occurs

  """

  try:
    if not args.append:
      metadata.signatures = []

    signature = None
    # If the cli tool was called with `--gpg [KEYID ...]` `args.gpg` is
    # a list (not None) and we will try to sign with gpg.
    # If `--gpg-home` was not set, args.gpg_home is None and the signer tries
    # to use the default gpg keyring.
    if args.gpg is not None:
      # If `--gpg` was passed without argument we sign with the default key
      # Excluded so that coverage does not vary in different test environments
      if len(args.gpg) == 0: # pragma: no cover
        signature = metadata.sign_gpg(gpg_keyid=None, gpg_home=args.gpg_home)

      # Otherwise we sign with each passed keyid
      for keyid in args.gpg:
        securesystemslib.formats.KEYID_SCHEMA.check_match(keyid)
        signature = metadata.sign_gpg(gpg_keyid=keyid, gpg_home=args.gpg_home)

    # Alternatively we iterate over passed private key paths `--key KEYPATH
    # ...` load the corresponding key from disk and sign with it
    elif args.key is not None: # pragma: no branch

      if args.key_type is None:
        args.key_type = [KEY_TYPE_RSA] * len(args.key)

      if len(args.key_type) != len(args.key):
        raise securesystemslib.exceptions.FormatError(
          "number of key_types should match with the number"
          " of keys specified")

      for idx, key_path in enumerate(args.key):
        key = interface.import_privatekey_from_file(
            key_path, key_type=args.key_type[idx], prompt=args.prompt)
        signature = metadata.sign(key)

    # If `--output` was specified we store the signed link or layout metadata
    # to that location no matter what
    if args.output:
      out_path = args.output

    # Otherwise, in case of links, we build the filename using the link/step
    # name and the keyid of the created signature (there is only one for links)
    elif metadata.type_ == "link":
      securesystemslib.formats.ANY_SIGNATURE_SCHEMA.check_match(signature)
      keyid = signature["keyid"]
      out_path = FILENAME_FORMAT.format(step_name=metadata.signed.name,
          keyid=keyid)

    # In case of layouts we just override the input file.
    elif metadata.type_ == "layout": # pragma: no branch
      out_path = args.file

    LOG.info("Dumping {0} to '{1}'...".format(metadata.type_, out_path))

    metadata.dump(out_path)
    sys.exit(0)

  except Exception as e:
    LOG.error("The following error occurred while signing: "
        "{}".format(e))
    sys.exit(2)


def _verify_metadata(metadata, args):
  """
  <Purpose>
    Internal method to verify link or layout signatures.

  <Arguments>
    metadata:
            Metablock object (contains Link or Layout object)
    args:
            see argparser

  <Exceptions>
    SystemExit(0) if verification passes
    SystemExit(1) if verification fails
    SystemExit(2) if any exception occurs

  """
  try:
    # Load pubkeys from disk ....
    if args.key is not None:
      pub_key_dict = interface.import_publickeys_from_file(args.key,
          args.key_type)

    # ... or from gpg keyring
    elif args.gpg is not None: # pragma: no branch
      pub_key_dict = gpg_interface.export_pubkeys(
          args.gpg, args.gpg_home)


    for keyid, verification_key in pub_key_dict.items():
      metadata.verify_signature(verification_key)
      LOG.info("Signature verification passed for keyid '{}'"
          .format(keyid))

    sys.exit(0)

  except exceptions.SignatureVerificationError as e:
    LOG.error("Signature verification failed: {}".format(e))
    sys.exit(1)

  except Exception as e:
    LOG.error("The following error occurred while verifying signatures: "
        "{}".format(e))
    sys.exit(2)


def _load_metadata(file_path):
  """
  <Purpose>
    Loads Metablock (link or layout metadata) file from disk

  <Arguments>
    file_path:
            path to link or layout metadata file

  <Exceptions>
    SystemExit(2) if any exception occurs

  <Returns>
    in-toto Metablock object (contains Link or Layout object)

  """
  try:
    return Metablock.load(file_path)

  except Exception as e:
    LOG.error("The following error occurred while loading the file '{}': "
        "{}".format(file_path, e))
    sys.exit(2)


def create_parser():
  """Create and return configured ArgumentParser instance. """
  parser = argparse.ArgumentParser(
      formatter_class=argparse.RawDescriptionHelpFormatter,
      description="""
in-toto-sign provides a command line interface to sign in-toto link or layout
metadata or verify their signatures, with options to:

  * replace (default) or add signatures:

    + layout metadata can be signed by multiple keys at once,
    + link metadata can only be signed by one key at a time.

  * write signed metadata to a specified path. If no output path is specified,

    + layout metadata is written to the path of the input file,
    + link metadata is written to '<name>.<keyid prefix>.link'.

  * verify signatures

in-toto-sign is useful to re-sign metadata (e.g. when changing keys), or to
sign unsigned links (e.g. generated with 'in-toto-mock'). For layouts, it is
useful to append signatures in case threshold signing of layouts is necessary.

It returns a non-zero value on failure and zero otherwise.""")

  parser.epilog = """EXAMPLE USAGE

Sign 'unsigned.layout' with two keys and write it to 'root.layout'.

  {prog} -f unsigned.layout -k priv_key1 priv_key2 -o root.layout


Replace signature in link file and write to default filename, i.e.
'package.<priv_key keyid prefix>.link'.

  {prog} -f package.2f89b927.link -k priv_key


Verify layout signed with 3 keys.

  {prog} -f root.layout -k pub_key0 pub_key1 pub_key2 --verify


Sign layout with default gpg key in default gpg keyring.

  {prog} -f root.layout --gpg


Verify layout with a gpg key identified by keyid '...439F3C2'.

  {prog} -f root.layout --verify \\
      --gpg 3BF8135765A07E21BD12BF89A5627F6BF439F3C2

""".format(prog=parser.prog)


  named_args = parser.add_argument_group("required named arguments")

  named_args.add_argument("-f", "--file", type=str, required=True,
      metavar="<path>", help=(
        "path to link or layout file to be signed or verified."))

  parser.add_argument("-k", "--key", nargs="+", metavar="<path>", help=(
      "paths to key files, used to sign the passed link or layout metadata"
      " or to verify its signatures. See '--key-type' for available formats."))

  parser.add_argument("-t", "--key-type", dest="key_type",
      type=str, choices=SUPPORTED_KEY_TYPES,
      nargs="+", help=(
      "types of keys specified by the '--key' option. '{rsa}' keys are"
      " expected in a 'PEM' format and '{ed25519}' in a custom"
      " 'securesystemslib/json' format. If multiple keys are passed via"
      " '--key' the same amount of key types must be passed. Key"
      " types are then associated with keys by index. If '--key-type' is"
      " omitted, the default of '{rsa}' is used for all keys.".format(
      rsa=KEY_TYPE_RSA, ed25519=KEY_TYPE_ED25519)))

  parser.add_argument("-p", "--prompt", action="store_true",
      help="prompt for signing key decryption password")

  parser.add_argument("-g", "--gpg", nargs="*", metavar="<id>", help=(
      "GPG keyids used to sign the passed link or layout metadata or to verify"
      " its signatures. If passed without keyids, the default GPG key is"
      " used."))

  parser.add_argument(*GPG_HOME_ARGS, **GPG_HOME_KWARGS)

  # Only when signing
  parser.add_argument("-o", "--output", type=str, metavar="<path>",
      help=(
      "path to location where the metadata file is stored after signing. If"
      " not passed, layout metadata is written to the path of the input file"
      " and link metadata is written to '<name>.<keyid prefix>.link'"))

  # Only when signing
  parser.add_argument("-a", "--append", action="store_true",
      help=(
      "add signatures rather than replacing existing signatures. This option"
      " is only availabe for layout metdata."))

  parser.add_argument("--verify", action="store_true",
      help="verify signatures of passed link or layout metadata using the"
      " public keys passed via '--key' and/or '--gpg' options.")

  verbosity_args = parser.add_mutually_exclusive_group(required=False)
  verbosity_args.add_argument(*VERBOSE_ARGS, **VERBOSE_KWARGS)
  verbosity_args.add_argument(*QUIET_ARGS, **QUIET_KWARGS)

  parser.add_argument('--version', action='version',
                      version='{} {}'.format(parser.prog, __version__))

  title_case_action_groups(parser)
  sort_action_groups(parser)

  return parser


def main():
  """Parse arguments, load link or layout metadata file and either sign
  metadata file or verify its signatures. """

  parser = create_parser()
  args = parser.parse_args()

  LOG.setLevelVerboseOrQuiet(args.verbose, args.quiet)

  # Additional argparse sanitization
  # NOTE: This tool is starting to have many inter-dependent argument
  # restrictions. Maybe we should make it less sophisticated at some point.
  if args.verify and (args.append or args.output):
    parser.print_help()
    parser.error("conflicting arguments: don't specify any of"
        " 'append' or 'output' when verifying signatures")

  # Regular signing and GPG signing are mutually exclusive
  if (args.key is None) == (args.gpg is None):
    parser.print_help()
    parser.error("wrong arguments: specify either '--key PATH [PATH ...]'"
      " or '--gpg [KEYID [KEYID ...]]'")

  # For gpg verification we must specify a keyid (no default key is loaded)
  if args.verify and args.gpg is not None and len(args.gpg) < 1:
    parser.print_help()
    parser.error("missing arguments: specify at least one keyid for GPG"
      " signature verification ('--gpg KEYID ...')")

  metadata = _load_metadata(args.file)

  # Specific command line argument restrictions if we deal with links
  if metadata.type_ == "link":
    # Above we check that it's either `--key ...` or `--gpg ...`
    # Here we check that it is not more than one in each case when dealing
    # with links
    link_error_message = ("link metadata is associated with a"
        " single functionary and is usually namespaced accordingly:"
        " '<name>.<keyid prefix>.link'.")

    if ((args.key is not None and len(args.key) > 1) or
        (args.gpg is not None and len(args.gpg) > 1)):
      parser.print_help()
      parser.error("too many arguments: {} Hence signing Link metadata"
          " with multiple keys is not allowed.".format(link_error_message))

    if args.append:
      parser.print_help()
      parser.error("wrong arguments: {}. Hence adding signatures to"
          " existing signatures on link metadata is not allowed."
          .format(link_error_message))


  if args.verify:
    _verify_metadata(metadata, args)

  else:
    _sign_and_dump_metadata(metadata, args)


if __name__ == "__main__":
  main()
