#!/usr/bin/env python

from __future__ import print_function

"""
A somewhat complicated client for SignServer

Based on the simple sign.py SignServer client as a base class,
we generate a hash for a file by taking the contents of the file
and append the appropriate additional bytes as specified in RFC 4880
to provide for either an OpenPGP V3 or V4 detached signature for a
binary document.

A full set of command-line options may be provided by using the --help
command-line argument.

The current --help output is as follows:

usage: openpgp-sign.py [--help] [-C CERTFILE] [-c CONFIG] [-d]
                       [-h {sha256,sha384,sha512,sha224,sha1}] [-o OFILE]
                       [-r TRIES] [-u URL] [-T FILESFROM] [-a] [--no-armor]
                       [--force-v3-sigs] [--no-force-v3-sigs]
                       [File [File ...]]

positional arguments:
  File                  File to be signed

optional arguments:
  --help                show this message
  -C CERTFILE           Certificate of public key
  -c CONFIG             Configuration file with defaults
  -d                    Add debugging output.
  -h {sha256,sha384,sha512,sha224,sha1}
                        Hashing algorithm to be used (default sha256).
  -o OFILE              Print sign output to ofile (default='.').
  -r TRIES              Number of times to retry server connections
  -u URL                Connect to the given "url" (host:port)
  -T FILESFROM, --files-from FILESFROM
                        get names to hash from FILE
  -a, --armor           Create ASCII armored output
  --no-armor            Create binary OpenPGP format
  --force-v3-sigs       Use v3 signature format
  --no-force-v3-sigs    Use default v4 signature format

Signed files will be given a .psig suffix for binary signatures and
.asc suffix for ascii armored signatures.
"""

"""
RCSid:
	$Id: openpgp-sign.py,v 1.17 2025/04/18 23:09:27 sjg Exp $

	@(#) Copyright (c) 2015-2023 Simon J. Gerraty
	@(#) Copyright (c) 2014, 2015, Juniper Networks, Inc.

	This file is provided in the hope that it will
	be of use.  There is absolutely NO WARRANTY.
	Permission to copy, redistribute or otherwise
	use this file is hereby granted provided that
	the above copyright notice and this notice are
	left intact.

	This file uses sign.py as a base class.

	Please send copies of changes and bug-fixes to:
	sjg@crufty.net, ca@juniper.net
"""

import array
import base64
import binascii
import os
import pwd
import sys
import time

sys.dont_write_bytecode = True

import sign

def extra_options(parser, conf=None):
    """Add extra options to the argparse set"""
    if not parser:
        return

    parser.add_argument('-a', '--armor', dest='armor',
                        help="Create ASCII armored output",
                        action='store_true')
    parser.add_argument('--no-armor', dest='armor',
                        help="Create binary OpenPGP format",
                        action='store_false')
    parser.add_argument('--force-v3-sigs', dest='sigformat',
                        help="Use v3 signature format",
                        default='v4', action='store_const', const='v3')
    parser.add_argument('--no-force-v3-sigs', dest='sigformat',
                        help="Use default v4 signature format",
                        default='v4', action='store_const', const='v4')

# RFC 4880 - 9.4.  Hash Algorithms
pgp_hash_algo = { 'sha1':   sign.b('\x02'), 'sha256': sign.b('\x08'),
                  'sha384': sign.b('\x09'), 'sha512': sign.b('\x0a'),
                  'sha224': sign.b('\x0b') }

# This is a copy of code also found in ima-sign.py
def i2octets(n, i):
    """Convert an integer i into n octets"""
    v = bytearray()
    for pos in reversed(range(n)):
        v.append((i & (0xff << pos * 8)) >> pos * 8)
    return v

# This is a copy of code also found in pem.py
def pem_decode(text):
    """extract and decode pem data"""
    # pem = data between the BEGIN and END markers
    text = sign.s(text)
    begin = text.find('-----BEGIN')
    if begin >= 0:
        begin = text.find('\n', begin) + 1
        if begin > 0:
            pem = text[begin:]
            end = pem.find('-----END')
            if end > 0:
                pem = pem[:end]
                pem = pem.replace('\n', '')
            else:
                return None
        else:
            return None
    else:
        return None

    data = base64.b64decode(pem)
    return sign.s(data)

# Valid Armor Headers found in RFC 4880
armor_headers = ['Version', 'Comment', 'MessageId', 'Hash', 'Charset']

def crc24(octets):
    """Calculate the CRC24 of RFC 4880

    The Armor Checksum - a 24-bit Cyclic Redundancy Check (CRC)
    converted to four characters of radix-64 encoding by the same MIME
    base64 transformation, preceded by an equal sign (=). The CRC is
    computed using the generator 0x1864CFB and an initialization of
    0xB704CE. The accumulation is done before it is converted to
    radix-64, rather than on the convered data.
    """
    crc24_init = 0xb704ce
    crc24_poly = 0x1864cfb
    crc = crc24_init
    for octet in array.array('B', octets):
        crc ^= (octet << 16)
        for i in range(8):
            crc <<= 1
            if crc & 0x1000000:
                crc ^= crc24_poly
    c = [(crc & (0xff << pos*8)) >> pos*8 for pos in reversed(range(3))]
    if sys.version_info[0] == 2:
        return array.array('B', c).tostring()
    return array.array('B', c).tobytes()

def armor_encode(id, headers, data):
    """AsciiArmor encode data with a given id"""
    # Start with an Armor Header line
    atext = ['-----BEGIN {0}-----'.format(id)]
    # Armor Headers go next
    # process each keyward,value pair in headers
    for h in armor_headers:
        if h in headers:
            atext.append(h + ': ' + headers[h])

    # A blank (zero-length, or containing only whitespace) line
    atext.append('')

    crc24value = crc24(sign.b(data))
    crc24b64 = base64.b64encode(sign.b(crc24value))
    # The ASCII-Armored data base64 encoding is identical to the MIME
    # base64 content-transfer-encoding [RFC 2045]
    b64 = base64.b64encode(sign.b(data))
    x = 0
    n = len(b64)
    while x < n:
        y = x + min(n - x, 64)
        atext.append(sign.s(b64[x:y]))
        x = y

    # End with an Armor Tail line
    atext.append('=' + sign.s(crc24b64))
    atext.append('-----END {0}-----\n'.format(id))
    return '\n'.join(atext)

class OpenPGPSignClient(sign.SignClient):
    """Update the SignClient to generate OpenPGP signatures"""

    def __init__(self, conf={}):
        """save setup"""

        super(OpenPGPSignClient, self).__init__(conf)
        self.hname = conf['hash']
        self.hid = pgp_hash_algo[self.hname]
        self.debug = sign.bump_debug(conf)

        # timestamp for all signatures created this session
        timebytes = i2octets(4, int(time.time()))
        pub_key_alg = sign.b('\x01')

        # One of two kinds of signature formats: v3 or v4.
        # Be careful in future if adding other variations.
        sigfmt = 'undefined'
        if 'sigformat' in conf:
            sigfmt = conf['sigformat']
        else:
            sign.error("Missing signature format information.\n")

        if sigfmt == 'v3':
            pgpbytes = sign.b('\x00') + timebytes
        elif sigfmt == 'v4':
            # RFC 4880: 5.2.3.4 Signature Creation Time
            # Four-octet time field: subpacket len + '\2' + 'xxxx'
            # The time the signature was made.
            # MUST be present in the hashed area.
            sigsub_create_time = sign.b('\x05\x02') + timebytes
            subpktlen = i2octets(2, len(sigsub_create_time))
            sig_hashed = sign.b('\x04\x00\x01') + self.hid + subpktlen + sigsub_create_time
            self.sig_hashed = sig_hashed
            pgpbytes = sig_hashed + sign.b('\x04\xff') + i2octets(4, len(sig_hashed))
        else:
            sign.error("The signature format '{}' is not understood".format(sigfmt))

        self.pgpbytes = pgpbytes

    def hash_file(self, file):
        """hash file using the indicated method"""
        h = self.hashfunc()
        hfile = '{}.{}'.format(file, h.name)
        f = open(file, 'rb')
        for line in f:
            h.update(line)
        f.close()
        pgpbytes = self.pgpbytes
        if len(pgpbytes) > 0:
            # Now we need to add the OpenPGP magic values
            h.update(pgpbytes)
            if self.debug > 0:
                print('adding pgpbytes to checksum for {}'.format(file))
        return h.hexdigest() + '\n'

    def sign(self, path, myhash, debug=0):
        """Sign the hash"""

        if self.debug > debug:
            debug = self.debug

        knobs, response = super(OpenPGPSignClient, self).sign(path, myhash, debug)

        # Convert the response to binary if necessary
        if response.find('BEGIN') >= 0:
            data = pem_decode(response)
        else:
            data = sign.s(response)

        if 'OpenPGPKeyid' in knobs:
            OpenPGPKeyid = knobs['OpenPGPKeyid']
        else:
            sign.error("This server is not configured for openpgp-sign operations")
        
        if self.conf.get('sigformat', 'v4') == 'v3':
            sig_packet = sign.b('\x03\05') + self.pgpbytes
            sig_packet += binascii.unhexlify(OpenPGPKeyid)
            sig_packet += sign.b('\x01') + self.hid
        else:
            # v4 has an Issuer in the unhashed section.
            # RFC 4880: 5.2.3.5.  Issuer (8-octet Key ID)
            sigsub_issuer = sign.b('\x09\x10') + binascii.unhexlify(OpenPGPKeyid)
            sig_unhashed = sigsub_issuer
            sig_unhashed_len = i2octets(2, len(sig_unhashed))
            sig_packet = self.sig_hashed + sig_unhashed_len + sig_unhashed

        sig_packet += binascii.unhexlify(sign.b(myhash[:4]))
        sig_packet += i2octets(2, len(data) * 8) + sign.b(data)

        # Prepend data with signature packet specificition RFC 4880 4.2.1
        #      +---------------+
        # PTag |7 6 5 4 3 2 1 0|
        #      +---------------+
        # Bit 7 -- Always one -> Binary Format
        # Bit 6 -- New packet format, set to zero for old format
        # Bits 5-2 packet tag == '0010' => Signature Packet
        # Bits 1-0 length-type, old format is easier for length calculations
        #   '00' => one octet length, encodes 0 to 255 octets
        #   '01' => two octet length, encodes 256 to 65535 octets
        #   '10' => four octet length, encodes 65536 to 4294967295 octets
        #   '11' => indeterminate length, more than 4294967296 octets
        if len(sig_packet) <= 255:
            data = sign.b('\x88') + i2octets(1, (len(sig_packet))) + sig_packet
        elif len(sig_packet) <= 65535:
            data = sign.b('\x89') + i2octets(2, (len(sig_packet))) + sig_packet
        if len(sig_packet) <= 4294967295:
            data = sign.b('\x8a') + i2octets(4, (len(sig_packet))) + sig_packet
        else:
            data = sign.b('\x8b') + sig_packet

        if sign.getBool(self.conf, 'armor'):
            akeys = { 'Comment': 'File {}'.format(path), 'Hash': self.hname }
            response = armor_encode('PGP SIGNATURE', akeys, data)
            knobs['sig_ext'] = '.asc'
        else:
            response = sign.b(data)
        return (knobs, response)

if __name__ == '__main__':
    try:
        sign.main(OpenPGPSignClient, extra_options)
    except SystemExit:
        raise
    except:
        # yes, this goes to stdout
        print("ERROR: {}".format(sys.exc_info()[1]))
        raise
    sys.exit(0)
