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 )