tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

sign_app.py (15138B)


      1 #!/usr/bin/env python3
      2 #
      3 # This Source Code Form is subject to the terms of the Mozilla Public
      4 # License, v. 2.0. If a copy of the MPL was not distributed with this
      5 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
      6 
      7 """
      8 Given a directory of files, packages them up and signs the
      9 resulting zip file. Mainly for creating test inputs to the
     10 nsIX509CertDB.openSignedAppFileAsync API.
     11 """
     12 from base64 import b64encode
     13 from cbor2 import dumps
     14 from cbor2.types import CBORTag
     15 from hashlib import sha1, sha256
     16 import argparse
     17 from io import StringIO
     18 import os
     19 import re
     20 import sys
     21 import zipfile
     22 
     23 # These libraries moved to security/manager/tools/ in bug 1699294.
     24 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
     25 import pycert
     26 import pycms
     27 import pykey
     28 
     29 ES256 = -7
     30 ES384 = -35
     31 ES512 = -36
     32 KID = 4
     33 ALG = 1
     34 COSE_Sign = 98
     35 
     36 
     37 def coseAlgorithmToPykeyHash(algorithm):
     38    """Helper function that takes one of (ES256, ES384, ES512)
     39    and returns the corresponding pykey.HASH_* identifier."""
     40    if algorithm == ES256:
     41        return pykey.HASH_SHA256
     42    if algorithm == ES384:
     43        return pykey.HASH_SHA384
     44    if algorithm == ES512:
     45        return pykey.HASH_SHA512
     46    raise UnknownCOSEAlgorithmError(algorithm)
     47 
     48 
     49 # COSE_Signature = [
     50 #     protected : serialized_map,
     51 #     unprotected : {},
     52 #     signature : bstr
     53 # ]
     54 
     55 
     56 def coseSignature(payload, algorithm, signingKey, signingCertificate, bodyProtected):
     57    """Returns a COSE_Signature structure.
     58    payload is a string representing the data to be signed
     59    algorithm is one of (ES256, ES384, ES512)
     60    signingKey is a pykey.ECKey to sign the data with
     61    signingCertificate is a byte string
     62    bodyProtected is the serialized byte string of the protected body header
     63    """
     64    protected = {ALG: algorithm, KID: signingCertificate}
     65    protectedEncoded = dumps(protected)
     66    # Sig_structure = [
     67    #     context : "Signature"
     68    #     body_protected : bodyProtected
     69    #     sign_protected : protectedEncoded
     70    #     external_aad : nil
     71    #     payload : bstr
     72    # ]
     73    sigStructure = ["Signature", bodyProtected, protectedEncoded, None, payload]
     74    sigStructureEncoded = dumps(sigStructure)
     75    pykeyHash = coseAlgorithmToPykeyHash(algorithm)
     76    signature = signingKey.signRaw(sigStructureEncoded, pykeyHash)
     77    return [protectedEncoded, {}, signature]
     78 
     79 
     80 # COSE_Sign = [
     81 #     protected : serialized_map,
     82 #     unprotected : {},
     83 #     payload : nil,
     84 #     signatures : [+ COSE_Signature]
     85 # ]
     86 
     87 
     88 def coseSig(payload, intermediates, signatures):
     89    """Returns the entire (tagged) COSE_Sign structure.
     90    payload is a string representing the data to be signed
     91    intermediates is an array of byte strings
     92    signatures is an array of (algorithm, signingKey,
     93               signingCertificate) triplets to be passed to
     94               coseSignature
     95    """
     96    protected = {KID: intermediates}
     97    protectedEncoded = dumps(protected)
     98    coseSignatures = []
     99    for algorithm, signingKey, signingCertificate in signatures:
    100        coseSignatures.append(
    101            coseSignature(
    102                payload, algorithm, signingKey, signingCertificate, protectedEncoded
    103            )
    104        )
    105    tagged = CBORTag(COSE_Sign, [protectedEncoded, {}, None, coseSignatures])
    106    return dumps(tagged)
    107 
    108 
    109 def walkDirectory(directory):
    110    """Given a relative path to a directory, enumerates the
    111    files in the tree rooted at that location. Returns a list
    112    of pairs of paths to those files. The first in each pair
    113    is the full path to the file. The second in each pair is
    114    the path to the file relative to the directory itself."""
    115    paths = []
    116    for path, _dirs, files in os.walk(directory):
    117        for f in files:
    118            fullPath = os.path.join(path, f)
    119            internalPath = re.sub(r"^/", "", fullPath.replace(directory, ""))
    120            paths.append((fullPath, internalPath))
    121    return paths
    122 
    123 
    124 def addManifestEntry(filename, hashes, contents, entries):
    125    """Helper function to fill out a manifest entry.
    126    Takes the filename, a list of (hash function, hash function name)
    127    pairs to use, the contents of the file, and the current list
    128    of manifest entries."""
    129    entry = "Name: %s\n" % filename
    130    for hashFunc, name in hashes:
    131        base64hash = b64encode(hashFunc(contents).digest()).decode("ascii")
    132        entry += "%s-Digest: %s\n" % (name, base64hash)
    133    entries.append(entry)
    134 
    135 
    136 def getCert(subject, keyName, issuerName, ee, issuerKey="", validity=""):
    137    """Helper function to create an X509 cert from a specification.
    138    Takes the subject, the subject key name to use, the issuer name,
    139    a bool whether this is an EE cert or not, and optionally an issuer key
    140    name."""
    141    certSpecification = (
    142        "issuer:%s\n" % issuerName
    143        + "subject:"
    144        + subject
    145        + "\n"
    146        + "subjectKey:%s\n" % keyName
    147    )
    148    if ee:
    149        certSpecification += "extension:keyUsage:digitalSignature"
    150    else:
    151        certSpecification += (
    152            "extension:basicConstraints:cA,\n"
    153            + "extension:keyUsage:cRLSign,keyCertSign"
    154        )
    155    if issuerKey:
    156        certSpecification += "\nissuerKey:%s" % issuerKey
    157    if validity:
    158        certSpecification += "\nvalidity:%s" % validity
    159    certSpecificationStream = StringIO()
    160    print(certSpecification, file=certSpecificationStream)
    161    certSpecificationStream.seek(0)
    162    return pycert.Certificate(certSpecificationStream)
    163 
    164 
    165 def coseAlgorithmToSignatureParams(coseAlgorithm, issuerName, issuerKey, certValidity):
    166    """Given a COSE algorithm ('ES256', 'ES384', 'ES512'), an issuer
    167    name, the name of the issuer's key, and a validity period, returns a
    168    (algorithm id, pykey.ECCKey, encoded certificate) triplet for use
    169    with coseSig.
    170    """
    171    if coseAlgorithm == "ES256":
    172        keyName = "secp256r1"
    173        algId = ES256
    174    elif coseAlgorithm == "ES384":
    175        keyName = "secp384r1"
    176        algId = ES384
    177    elif coseAlgorithm == "ES512":
    178        keyName = "secp521r1"  # COSE uses the hash algorithm; this is the curve
    179        algId = ES512
    180    else:
    181        raise UnknownCOSEAlgorithmError(coseAlgorithm)
    182    key = pykey.ECCKey(keyName)
    183    # The subject must differ to avoid errors when importing into NSS later.
    184    ee = getCert(
    185        "xpcshell signed app test signer " + keyName,
    186        keyName,
    187        issuerName,
    188        True,
    189        issuerKey,
    190        certValidity,
    191    )
    192    return (algId, key, ee.toDER())
    193 
    194 
    195 def signZip(
    196    appDirectory,
    197    outputFile,
    198    issuerName,
    199    rootName,
    200    rootKey,
    201    certValidity,
    202    manifestHashes,
    203    signatureHashes,
    204    pkcs7Hashes,
    205    coseAlgorithms,
    206    emptySignerInfos,
    207    headerPaddingFactor,
    208 ):
    209    """Given a directory containing the files to package up, an output
    210    filename to write to, the name of the issuer of the signing
    211    certificate, the name of trust anchor, the name of the trust
    212    anchor's key, a list of hash algorithms to use in the manifest file,
    213    a similar list for the signature file, a similar list for the pkcs#7
    214    signature, a list of COSE signature algorithms to include, whether
    215    the pkcs#7 signer info should be kept empty, and how many MB to pad
    216    the manifests by (to test handling large manifest files), packages
    217    up the files in the directory and creates the output as
    218    appropriate."""
    219    # The header of each manifest starts with the magic string
    220    # 'Manifest-Version: 1.0' and ends with a blank line. There can be
    221    # essentially anything after the first line before the blank line.
    222    mfEntries = ["Manifest-Version: 1.0"]
    223    if headerPaddingFactor > 0:
    224        # In this format, each line can only be 72 bytes long. We make
    225        # our padding 50 bytes per line (49 of content and one newline)
    226        # so the math is easy.
    227        singleLinePadding = "a" * 49
    228        # 1000000 / 50 = 20000
    229        allPadding = [singleLinePadding] * (headerPaddingFactor * 20000)
    230        mfEntries.extend(allPadding)
    231    # Append the blank line.
    232    mfEntries.append("")
    233 
    234    issuerKey = rootKey
    235    with zipfile.ZipFile(outputFile, "w", zipfile.ZIP_DEFLATED) as outZip:
    236        for fullPath, internalPath in walkDirectory(appDirectory):
    237            with open(fullPath, "rb") as inputFile:
    238                contents = inputFile.read()
    239            outZip.writestr(internalPath, contents)
    240 
    241            # Add the entry to the manifest we're building
    242            addManifestEntry(internalPath, manifestHashes, contents, mfEntries)
    243 
    244        if len(coseAlgorithms) > 0:
    245            coseManifest = "\n".join(mfEntries)
    246            outZip.writestr("META-INF/cose.manifest", coseManifest)
    247            coseManifestBytes = coseManifest.encode()
    248            addManifestEntry(
    249                "META-INF/cose.manifest", manifestHashes, coseManifestBytes, mfEntries
    250            )
    251            intermediates = []
    252            coseIssuerName = issuerName
    253            if rootName:
    254                issuerKey = "default"
    255                coseIssuerName = "xpcshell signed app test issuer"
    256                intermediate = getCert(
    257                    coseIssuerName,
    258                    issuerKey,
    259                    rootName,
    260                    False,
    261                    rootKey,
    262                    certValidity,
    263                )
    264                intermediate = intermediate.toDER()
    265                intermediates.append(intermediate)
    266            signatures = [
    267                coseAlgorithmToSignatureParams(
    268                    coseAlgorithm,
    269                    coseIssuerName,
    270                    issuerKey,
    271                    certValidity,
    272                )
    273                for coseAlgorithm in coseAlgorithms
    274            ]
    275            coseSignatureBytes = coseSig(coseManifestBytes, intermediates, signatures)
    276            outZip.writestr("META-INF/cose.sig", coseSignatureBytes)
    277            addManifestEntry(
    278                "META-INF/cose.sig", manifestHashes, coseSignatureBytes, mfEntries
    279            )
    280 
    281        if len(pkcs7Hashes) != 0 or emptySignerInfos:
    282            mfContents = "\n".join(mfEntries)
    283            sfContents = "Signature-Version: 1.0\n"
    284            for hashFunc, name in signatureHashes:
    285                hashed = hashFunc(mfContents.encode()).digest()
    286                base64hash = b64encode(hashed).decode("ascii")
    287                sfContents += "%s-Digest-Manifest: %s\n" % (name, base64hash)
    288 
    289            cmsSpecification = ""
    290            for name in pkcs7Hashes:
    291                hashFunc, _ = hashNameToFunctionAndIdentifier(name)
    292                cmsSpecification += "%s:%s\n" % (
    293                    name,
    294                    hashFunc(sfContents.encode()).hexdigest(),
    295                )
    296            cmsSpecification += (
    297                "signer:\n"
    298                + "issuer:%s\n" % issuerName
    299                + "subject:xpcshell signed app test signer\n"
    300                + "extension:keyUsage:digitalSignature"
    301            )
    302            if issuerKey != "default":
    303                cmsSpecification += "\nissuerKey:%s" % issuerKey
    304            if certValidity:
    305                cmsSpecification += "\nvalidity:%s" % certValidity
    306            cmsSpecificationStream = StringIO()
    307            print(cmsSpecification, file=cmsSpecificationStream)
    308            cmsSpecificationStream.seek(0)
    309            cms = pycms.CMS(cmsSpecificationStream)
    310            p7 = cms.toDER()
    311            outZip.writestr("META-INF/A.RSA", p7)
    312            outZip.writestr("META-INF/A.SF", sfContents)
    313            outZip.writestr("META-INF/MANIFEST.MF", mfContents)
    314 
    315 
    316 class Error(Exception):
    317    """Base class for exceptions in this module."""
    318 
    319    pass
    320 
    321 
    322 class UnknownHashAlgorithmError(Error):
    323    """Helper exception type to handle unknown hash algorithms."""
    324 
    325    def __init__(self, name):
    326        super(UnknownHashAlgorithmError, self).__init__()
    327        self.name = name
    328 
    329    def __str__(self):
    330        return "Unknown hash algorithm %s" % repr(self.name)
    331 
    332 
    333 class UnknownCOSEAlgorithmError(Error):
    334    """Helper exception type to handle unknown COSE algorithms."""
    335 
    336    def __init__(self, name):
    337        super(UnknownCOSEAlgorithmError, self).__init__()
    338        self.name = name
    339 
    340    def __str__(self):
    341        return "Unknown COSE algorithm %s" % repr(self.name)
    342 
    343 
    344 def hashNameToFunctionAndIdentifier(name):
    345    if name == "sha1":
    346        return (sha1, "SHA1")
    347    if name == "sha256":
    348        return (sha256, "SHA256")
    349    raise UnknownHashAlgorithmError(name)
    350 
    351 
    352 def main(outputFile, appPath, *args):
    353    """Main entrypoint. Given an already-opened file-like
    354    object, a path to the app directory to sign, and some
    355    optional arguments, signs the contents of the directory and
    356    writes the resulting package to the 'file'."""
    357    parser = argparse.ArgumentParser(description="Sign an app.")
    358    parser.add_argument(
    359        "-i",
    360        "--issuer",
    361        action="store",
    362        help="Issuer name",
    363        default="xpcshell signed apps test root",
    364    )
    365    parser.add_argument("-r", "--root", action="store", help="Root name", default="")
    366    parser.add_argument(
    367        "-k",
    368        "--root-key",
    369        action="store",
    370        help="Root key name",
    371        default="default",
    372    )
    373    parser.add_argument(
    374        "--cert-validity",
    375        action="store",
    376        help="Certificate validity; YYYYMMDD-YYYYMMDD or duration in days",
    377        default="",
    378    )
    379    parser.add_argument(
    380        "-m",
    381        "--manifest-hash",
    382        action="append",
    383        help="Hash algorithms to use in manifest",
    384        default=[],
    385    )
    386    parser.add_argument(
    387        "-s",
    388        "--signature-hash",
    389        action="append",
    390        help="Hash algorithms to use in signature file",
    391        default=[],
    392    )
    393    parser.add_argument(
    394        "-c",
    395        "--cose-sign",
    396        action="append",
    397        help="Append a COSE signature with the given "
    398        + "algorithms (out of ES256, ES384, and ES512)",
    399        default=[],
    400    )
    401    parser.add_argument(
    402        "-z",
    403        "--pad-headers",
    404        action="store",
    405        default=0,
    406        help="Pad the header sections of the manifests "
    407        + "with X MB of repetitive data",
    408    )
    409    group = parser.add_mutually_exclusive_group()
    410    group.add_argument(
    411        "-p",
    412        "--pkcs7-hash",
    413        action="append",
    414        help="Hash algorithms to use in PKCS#7 signature",
    415        default=[],
    416    )
    417    group.add_argument(
    418        "-e",
    419        "--empty-signerInfos",
    420        action="store_true",
    421        help="Emit pkcs#7 SignedData with empty signerInfos",
    422    )
    423    parsed = parser.parse_args(args)
    424    if len(parsed.manifest_hash) == 0:
    425        parsed.manifest_hash.append("sha256")
    426    if len(parsed.signature_hash) == 0:
    427        parsed.signature_hash.append("sha256")
    428    signZip(
    429        appPath,
    430        outputFile,
    431        parsed.issuer,
    432        parsed.root,
    433        parsed.root_key,
    434        parsed.cert_validity,
    435        [hashNameToFunctionAndIdentifier(h) for h in parsed.manifest_hash],
    436        [hashNameToFunctionAndIdentifier(h) for h in parsed.signature_hash],
    437        parsed.pkcs7_hash,
    438        parsed.cose_sign,
    439        parsed.empty_signerInfos,
    440        int(parsed.pad_headers),
    441    )