From e1c1725d53abf7739d55c3fded141100fa581aa7 Mon Sep 17 00:00:00 2001 From: ale Date: Wed, 2 Aug 2006 01:23:58 +0000 Subject: [PATCH] import iniziale --- cam | 84 +++++++++++++++++++++++++++++++++++ config-example | 30 +++++++++++++ lib/cfg.py | 12 +++++ lib/cfg.pyc | Bin 0 -> 616 bytes lib/check.py | 26 +++++++++++ lib/files.py | 32 ++++++++++++++ lib/gen.py | 111 +++++++++++++++++++++++++++++++++++++++++++++++ lib/initfs.py | 16 +++++++ lib/list.py | 69 +++++++++++++++++++++++++++++ lib/newca.py | 62 ++++++++++++++++++++++++++ lib/paths.py | 25 +++++++++++ lib/templates.py | 102 +++++++++++++++++++++++++++++++++++++++++++ lib/utils.py | 72 ++++++++++++++++++++++++++++++ lib/utils.pyc | Bin 0 -> 3313 bytes 14 files changed, 641 insertions(+) create mode 100755 cam create mode 100644 config-example create mode 100644 lib/cfg.py create mode 100644 lib/cfg.pyc create mode 100644 lib/check.py create mode 100644 lib/files.py create mode 100644 lib/gen.py create mode 100644 lib/initfs.py create mode 100644 lib/list.py create mode 100644 lib/newca.py create mode 100644 lib/paths.py create mode 100644 lib/templates.py create mode 100644 lib/utils.py create mode 100644 lib/utils.pyc diff --git a/cam b/cam new file mode 100755 index 0000000..740b5d7 --- /dev/null +++ b/cam @@ -0,0 +1,84 @@ +#!/usr/bin/python + + +import os, sys +import logging +sys.path.append(os.path.join(os.path.dirname(sys.argv[0]), 'lib')) + +from utils import * +from cfg import * + +# commands +from gen import gen +from newca import newca +from list import list +from files import files +from initfs import initfs +from check import check + + +def Usage(): + print ''' +CAM v0.1 - (c)2006 by +A Certification Authority manager for complex situations. +Usage: %s [...] +Known commands: + + init + Initialize the environment by creating the necessary + directory structure + + newca [ []] + Create a new CA certificate (otherwise you can import + your own certificates) + + gen ... + Create (or re-create) the certificates corresponding + to TAG + + list + List all known certificates + + files ... + Dump all the certificate-related files of this TAG + + check + Should be run weekly from a cron job to warn you if + some certificates are about to expire (controlled by + the 'warning_days' parameter in the 'global' section + of the configuration) + + +The configuration will be read from '%s'. +It consists of a ini-style file, with one 'ca' section that +specifies global CA parameters, and more sections for each +tag with certificate-specific information. See the examples +for more details on how to write your own configuration. + +''' % (sys.argv[0], config_file_path) + sys.exit(0) + + +if len(sys.argv) < 2 or sys.argv[1] == 'help': + Usage() + +cmd = sys.argv[1] +if cmd == 'init': + initfs() +elif cmd == 'gen': + for tag in sys.argv[2:]: + gen(tag) +elif cmd == 'newca': + newca() +elif cmd == 'list': + list(sys.argv[2:]) +elif cmd == 'files': + for tag in sys.argv[2:]: + files(tag) +elif cmd == 'check': + check() +else: + print 'Unknown command \'%s\'.' % cmd + Usage() + + diff --git a/config-example b/config-example new file mode 100644 index 0000000..4adcee5 --- /dev/null +++ b/config-example @@ -0,0 +1,30 @@ + +[global] +root = /src/cam/test + +[ca] +name = Example Certification Authority +org = Example +country = AA +default_days = 365 +email = ca@domain.org +base_url = http://ca.domain.org/public/ + +[web] +cn = www.domain.org +alt_names = www1.domain.org, www2.domain.org, + www3.domain.org, www4.domain.org, www5.domain.org +ou = Example web services + +[imap] +cn = mail.domain.org +alt_names = mail.domain.org, imap.domain.org, domain.org +ou = Example mail services + +[smtp] +cn = smtp.domain.org +alt_names = mx1.domain.org, mx2.domain.org, mx3.domain.org, + mx4.domain.org, mx5.domain.org, smtp.domain.org, + mx.domain.org +ou = Example mail services + diff --git a/lib/cfg.py b/lib/cfg.py new file mode 100644 index 0000000..965e4a1 --- /dev/null +++ b/lib/cfg.py @@ -0,0 +1,12 @@ + +__all__ = [ 'cfg', 'ca_base', 'ca', 'config_file_path' ] + +import os, sys +import ConfigParser +from utils import * + +cfg = ConfigParser.ConfigParser() +config_file_path = os.path.join(os.path.dirname(sys.argv[0]),'config') +cfg.read(config_file_path) +ca = cfg2dict(cfg, 'ca') +ca_base = cfg.get('global', 'root') diff --git a/lib/cfg.pyc b/lib/cfg.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a86d8be09231f8e663eaf626974b146876801f10 GIT binary patch literal 616 zcmZutO;5r=5PjPZ3Khg~^B*)GAo1!!6FnOfJ@wKo>{3@MC7o)*nSbG*^1paBCeAF7 zV5Hg3%)Xg@^JcET2I=?D=iLmuFJgbgKYg${ zF%}V?YzP-(1LQHRg6qH~`=ARO!1Z7{INvZwf(>(|urb^TYzhXI?aQ5;N^anDr!ZZZ zo=1k#@i9w9W5{MUFDVhPi>W3{fsg4-T)mvTvY5Nd7AxI8-)D+Z5_93$fI4ipRX!}M zda5gu9W-^_W^@-Y(p^vZ>_7gB_d6@q_+=VYPa-! z#x+a*YCWi%^8KjXtq!XVb(w_8E{&VD`vGP4(zb`QXHxe^?|wnMrN~uuAMWel3{uN8 o9$R3>DR|>YA5SVbov^U+YVEgrEpPqHQ7}?Hl>~j2seu}*Ut21F3IG5A literal 0 HcmV?d00001 diff --git a/lib/check.py b/lib/check.py new file mode 100644 index 0000000..a6a67ec --- /dev/null +++ b/lib/check.py @@ -0,0 +1,26 @@ + +import os +from time import time, strftime, localtime +from utils import * +from paths import * +from cfg import * + + +def check(): + now = time() + warning_days = 15 + if cfg.has_option('global', 'warning_days'): + warning_days = int(cfg.get('global', 'warning_days')) + for tag in cfg.sections(): + if tag == 'global' or tag == 'ca' or tag == 'DEFAULT': + continue + crt_file = getpath('rsa_crt', tag) + crt_date = getcertdate(crt_file) + if crt_date: + days = int((now - crt_date)/86400) + if days > -warning_days: + print ''' +The certificate '%s' should be renewed! +(Expiration date: %s) +''' % (tag, strftime('%d/%m/%Y', localtime(crt_date))) + diff --git a/lib/files.py b/lib/files.py new file mode 100644 index 0000000..5f16887 --- /dev/null +++ b/lib/files.py @@ -0,0 +1,32 @@ + +import os +from cfg import * +from paths import * + +fields = [ + ('conf', 'OpenSSL configuration file'), + ('rsa_key', 'RSA private key'), + ('rsa_crt', 'RSA certificate'), + ('dsa_key', 'DSA private key'), + ('dsa_crt', 'DSA certificate'), + ('dsa_parms', 'DSA parameters'), + ('public_crt', 'public certificates bundle'), + ('singlefile', 'single cert. file (with keys)') + ] + +def files(tag): + print ''' + +Files related to the '%s' certificates: + +''' % tag + for k, desc in fields: + p = getpath(k, tag) + star = ' ' + if not os.path.exists(p): + star = '*' + print '%-30s %s%s' % (desc, star, p) + + print '(* = not found)' + print + diff --git a/lib/gen.py b/lib/gen.py new file mode 100644 index 0000000..526e52f --- /dev/null +++ b/lib/gen.py @@ -0,0 +1,111 @@ + + +import os, sys +import logging +from utils import * +from templates import * +from paths import * +from cfg import * + + +def gen(tag): + + info = cfg2dict(cfg, tag) + + conf_file = getpath('conf', tag) + rsa_key_file = getpath('rsa_key', tag) + dsa_key_file = getpath('dsa_key', tag) + dsa_parms_file = getpath('dsa_parms', tag) + csr_file = getpath('rsa_csr', tag) + dsa_csr_file = getpath('dsa_csr', tag) + ext_file = getpath('ext', tag) + public_crt_file = getpath('public_crt', tag) + crt_file = getpath('rsa_crt', tag) + dsa_crt_file = getpath('dsa_crt', tag) + sf_file = getpath('singlefile', tag) + + if os.path.exists(public_crt_file): + print + if expired(getcertdate(public_crt_file)): + print 'Certificate has expired. Ready to re-generate.' + else: + print + ans = raw_input('This certificate seems to exist already (in %s).\nAre you really sure that you want to re-create it? [y/N] ' % crt_file) + if not ans or ans[0].lower() != 'y': + sys.exit(0) + + # create custom config file + template(conf_file, + openssl_conf_template, + dict( + ca_dir = ca_base, + default_days = ca['default_days'], + country = d2get(info, ca, 'country'), + org = d2get(info, ca, 'org'), + ou = d2get(info, ca, 'ou', ''), + cn = info['cn'], + email = d2get(info, ca, 'email'))) + + # create dsa parameters + openssl('dsaparam', '-out', dsa_parms_file, '1024') + + # create rsa key + openssl('req', '-batch', '-new', '-keyout', rsa_key_file, + '-config', conf_file, '-nodes', '-out', csr_file) + openssl('req', '-batch', '-new', '-newkey', 'dsa:' + dsa_parms_file, + '-keyout', dsa_key_file, '-nodes', + '-config', conf_file, '-out', dsa_csr_file) + + # create ext file + altnames = [ x.strip() for x in info['alt_names'].split(',') ] + altnames_s = '' + for i in range(len(altnames)): + altnames_s += 'DNS.%d=%s\n' % (i + 1, altnames[i]) + template(ext_file, + ext_template, + dict( + ca_name = ca['name'], + ca_base_url = ca['base_url'], + alt_names = altnames_s)) + + # sign requests + openssl('ca', '-days', ca['default_days'], + '-config', conf_file, '-batch', + '-policy', 'policy_anything', + '-out', crt_file, + '-extfile', ext_file, + '-infiles', csr_file) + openssl('ca', '-days', ca['default_days'], + '-config', conf_file, '-batch', + '-policy', 'policy_anything', + '-out', dsa_crt_file, + '-extfile', ext_file, + '-infiles', dsa_csr_file) + f = open(public_crt_file, 'w') + f.write(open(crt_file, 'r').read()) + f.write(open(dsa_crt_file, 'r').read()) + f.close() + + # create single-file file + f = open(sf_file, 'w') + f.write(open(crt_file, 'r').read()) + f.write(open(dsa_crt_file, 'r').read()) + f.write(open(rsa_key_file, 'r').read()) + f.write(open(dsa_key_file, 'r').read()) + f.close() + + logging.info('created certificate %s [%s]' % (tag, info['cn'])) + + print ''' +Certificate '%s': + + CN: %s + AltNames: %s + + RSA key: %s + DSA key: %s + public crt: %s + all-in-one: %s + +''' % (tag, info['cn'], ', '.join(altnames), + rsa_key_file, dsa_key_file, public_crt_file, sf_file) diff --git a/lib/initfs.py b/lib/initfs.py new file mode 100644 index 0000000..b2527bc --- /dev/null +++ b/lib/initfs.py @@ -0,0 +1,16 @@ + + +import os +import logging +from utils import * +from cfg import * + + +def initfs(): + mkdir(ca_base) + for sub in [ 'archive', 'ext', 'conf', + 'private', 'private/certs', 'private/single-file', + 'public', 'public/certs', 'public/crl', 'newcerts' ]: + mkdir(os.path.join(ca_base, sub)) + os.chmod(os.path.join(ca_base, 'private'), 0700) + logging.info('created directory structure') diff --git a/lib/list.py b/lib/list.py new file mode 100644 index 0000000..5b32508 --- /dev/null +++ b/lib/list.py @@ -0,0 +1,69 @@ + +import os, re +import commands +from time import time, strftime, localtime +from utils import * +from templates import * +from paths import * +from cfg import * + + + +def list(args): + verbose = 0 + if len(args) > 0 and args[0] == '-v': + verbose = 1 + + print ''' + +List of known certificates for '%s' CA: + +''' % ca['name'] + + i = 0 + sections = cfg.sections() + sections.sort() + for sec in sections: + if sec == 'ca' or sec == 'global': + continue + i += 1 + crt_file = getpath('rsa_crt', sec) + dsa_crt_file = getpath('dsa_crt', sec) + if os.path.exists(crt_file): + crt_date = getcertdate(crt_file) + days = int((time() - crt_date) / 86400) + if days > 0: + days_s = '%d days ago' % days + else: + days_s = '%d days left' % (-days) + if expired(crt_date): + m = 'EXPIRED %s' % days_s.upper() + star = '*' + else: + if verbose: + m = '\n%21s Expiration: %s (%s)' % \ + (' ', + strftime('%d %b %Y', localtime(crt_date)), + days_s) + else: + m = 'exp. %s' % strftime('%d/%m/%Y', + localtime(crt_date)) + star = ' ' + else: + m = 'NOT FOUND' + star = '*' + print '%3d. %s%-15s %s [%s] - %s' % (i, star, sec, cfg.get(sec, 'cn'), cfg.get(sec, 'alt_names').replace('\n', ' '), m) + if star != '*' and verbose: + # do fingerprints + for hname, hash in [ ('MD5', 'md5'), ('SHA1', 'sha1') ]: + for cypher, file in [ ( 'RSA', crt_file), ( 'DSA', dsa_crt_file ) ]: + fp = fingerprint(hash, file) + print '%21s %s %s fingerprint: %s' % ('', cypher, hname, fp) + + if verbose: + print + + print ''' +(* = certificate does not exist, create with 'cam gen ') +''' + diff --git a/lib/newca.py b/lib/newca.py new file mode 100644 index 0000000..f036552 --- /dev/null +++ b/lib/newca.py @@ -0,0 +1,62 @@ + +import os, logging +from utils import * +from templates import * +from cfg import * + + +def newca(): + + conf_file = os.path.join(ca_base, 'conf/ca.conf') + ca_file = os.path.join(ca_base, 'public/ca.pem') + ca_dsa_file = os.path.join(ca_base, 'public/ca-dsa.tmp') + ca_key_file = os.path.join(ca_base, 'private/ca.key') + ca_dsa_key_file = os.path.join(ca_base, 'private/ca-dsa.key') + ca_csr_file = os.path.join(ca_base, 'newcerts/ca.csr') + ca_dsa_csr_file = os.path.join(ca_base, 'newcerts/ca-dsa.csr') + dsa_parms_file = os.path.join(ca_base, 'private/ca.dsap') + + serial_file = os.path.join(ca_base, 'serial') + index_file = os.path.join(ca_base, 'index') + if not os.path.exists(serial_file): + open(serial_file, 'w').write('01') + if not os.path.exists(index_file): + open(index_file, 'w').close() + + template(conf_file, + openssl_conf_template, + dict( + ca_dir = ca_base, + default_days = ca['default_days'], + country = ca['country'], + org = ca['org'], + ou = ca.get('ou', ''), + cn = ca['name'], + email = ca['email'])) + if not os.path.exists(dsa_parms_file): + openssl('dsaparam', '-out', dsa_parms_file, '1024') + logging.info('generated CA DSA parameters') + if not os.path.exists(ca_file): + openssl('req', '-new', '-keyout', ca_key_file, + '-config', conf_file, '-batch', + '-out', ca_csr_file) + openssl('req', '-new', '-newkey', 'dsa:' + dsa_parms_file, + '-config', conf_file, '-batch', + '-keyout', ca_dsa_key_file, + '-out', ca_dsa_csr_file) + openssl('ca', + '-config', conf_file, '-batch', + '-keyfile', ca_key_file, + '-extensions', 'v3_ca', '-selfsign', + '-out', ca_file, + '-infiles', ca_csr_file) + openssl('ca', + '-config', conf_file, '-batch', + '-keyfile', ca_dsa_key_file, + '-extensions', 'v3_ca', '-selfsign', + '-out', ca_dsa_file, + '-infiles', ca_dsa_csr_file) + open(ca_file, 'a').write(open(ca_dsa_file, 'r').read()) + os.remove(ca_dsa_file) + logging.info('created CA certificates') + diff --git a/lib/paths.py b/lib/paths.py new file mode 100644 index 0000000..ce8234b --- /dev/null +++ b/lib/paths.py @@ -0,0 +1,25 @@ + +__all__ = [ 'getpath' ] + +import os +from cfg import * + + +path_exts = dict( + conf = 'conf/%s.conf', + rsa_key = 'private/%s.key', + dsa_key = 'private/%s-dsa.key', + dsa_parms = 'private/%s.dsap', + rsa_csr = 'newcerts/%s.csr', + dsa_csr = 'newcerts/%s-dsa.csr', + rsa_crt = 'private/certs/%s.pem', + dsa_crt = 'private/certs/%s-dsa.pem', + ext = 'ext/%s.ext', + public_crt = 'public/certs/%s.pem', + singlefile = 'private/single-file/%s.pem', + ) + +def getpath(what, tag): + return os.path.join(ca_base, path_exts[what] % tag) + + diff --git a/lib/templates.py b/lib/templates.py new file mode 100644 index 0000000..b2e02cf --- /dev/null +++ b/lib/templates.py @@ -0,0 +1,102 @@ + +openssl_conf_template = ''' +RANDFILE = %(ca_dir)s/.random + +[ ca ] +default_ca = CA_default + +[ CA_default ] +dir = %(ca_dir)s +certs = $dir/public/certs +crl_dir = $dir/public/crl +crl = $dir/public/crl.pem +database = $dir/index +serial = $dir/serial +new_certs_dir = $dir/newcerts +certificate = $dir/public/ca.pem +private_key = $dir/private/ca.key +x509_extensions = certificate_extensions +email_in_dn = no +default_days = %(default_days)s +default_crl_days = 31 +default_md = sha1 +preserve = yes +policy = policy_match + +[ policy_match ] +countryName = supplied +organizationName = supplied +organizationalUnitName = optional +commonName = supplied +emailAddress = supplied + +[ policy_anything ] +countryName = optional +organizationName = optional +organizationalUnitName = optional +commonName = supplied +emailAddress = optional + +[ req ] +default_bits = 4096 +default_md = sha1 +distinguished_name = req_distinguished_name +attributes = req_attributes +x509_extensions = v3_ca +string_mask = nombstr + +[ req_distinguished_name ] +countryName = Country Name +countryName_default = "%(country)s" +countryName_min = 2 +countryName_max = 2 +0.organizationName = Organization Name +0.organizationName_default = "%(org)s" +organizationalUnitName = Organizational Unit Name +organizationalUnitName_default = "%(ou)s" +commonName = Common Name +commonName_max = 64 +commonName_default = "%(cn)s" +emailAddress = Email Address +emailAddress_max = 60 +emailAddress_default = "%(email)s" +SET-ex3 = SET extension number 3 + +[ req_attributes ] + +[ certificate_extensions ] + +[ v3_ca ] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer:always +basicConstraints = critical, CA:true +keyUsage = cRLSign, keyCertSign +nsCertType = sslCA, emailCA, objCA +nsComment = "%(cn)s" +subjectAltName = email:copy +issuerAltName = issuer:copy + +''' + +ext_template = ''' +basicConstraints = CA:false +nsCertType = client, server +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +extendedKeyUsage = clientAuth, serverAuth +nsComment = "%(ca_name)s" +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid, issuer:always +subjectAltName = @subject_alt_name +issuerAltName = issuer:copy +nsCaRevocationUrl = %(ca_base_url)s/crl.pem +nsRevocationUrl = %(ca_base_url)s/crl.pem +crlDistributionPoints = @cdp_section + +[ subject_alt_name ] +%(alt_names)s +email = copy + +[ cdp_section ] +URI.1 = %(ca_base_url)s/crl.pem +''' + diff --git a/lib/utils.py b/lib/utils.py new file mode 100644 index 0000000..813ddef --- /dev/null +++ b/lib/utils.py @@ -0,0 +1,72 @@ + +__all__ = [ + 'getcertdate', 'fingerprint', 'expired', + 'cfg2dict', 'mkdir', 'template', + 'dictmerge', 'd2get', + 'openssl' + ] + + +import commands +import os, re +import time + + +def getcertdate(crt_file): + if os.path.exists(crt_file): + o = commands.getoutput('openssl x509 -in %s -noout -dates' % crt_file) + m = re.search(r'notAfter=(.*)', o) + if m: + return time.mktime(time.strptime(m.group(1), + '%b %d %H:%M:%S %Y %Z')) + return 0 + +def expired(crtdate): + if crtdate < time.time(): + return 1 + else: + return 0 + +def fingerprint(hash, crt_file): + if os.path.exists(crt_file): + o = commands.getoutput('openssl x509 -in %s -noout -fingerprint -%s' % (crt_file, hash)) + m = re.search(r'=(.*)$', o) + if m: + return m.group(1) + return 'NOT FOUND' + +def cfg2dict(cfg, section): + d = dict() + for k, v in cfg.items(section): + d[k] = v + return d + +def mkdir(p): + try: + os.mkdir(p, 0755) + print 'created directory \'%s\'' % p + except OSError: + pass + +def template(file, t, args): + f = open(file, 'w') + f.write(t % args) + f.close() + +def dictmerge(d1, d2): + out = dict() + for d in [ d2, d1 ]: + for k, v in d.items(): + out[k] = v + return out + +def d2get(d1, d2, k, default=None): + return d1.get(k, d2.get(k, default)) + +def openssl(*args): + cmd = "openssl " + ' '.join(["'%s'" % x for x in args]) + print 'executing "%s"' % cmd + return os.system(cmd) + + + diff --git a/lib/utils.pyc b/lib/utils.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe39ff82da3c9c592cfcde2a8f0c79a9a90d4593 GIT binary patch literal 3313 zcmb_eZEqVz5T3K0*iM}Em9+f;x|X_ifuytuQQD&NQrdpOkuXp&RFQjocCWGToZAa^ zq?Rx3-{3d#OZdW9gv9gA-q}t?ELFnE?#TGMVd; zuX2)c7+X7R2OgNp7!{1KFzg$^m!2d)h?i9)g54fJZQ$FBl4MnhU@y2z z^0Fi^um;{s;9ZeuRX8{JDT1|S$}tu4f#~c-;>DhL2tx;v>9m${YTKU2oNMM!KK}4i ze=~~xru8@DB-zjWO?8W116Q16U-Yvu{j9b1-WBKy#ZO~uKK7f&Z$7x${N`q}<2Qfu zo81WCrL|6$5~jpj+B)uKPb^OjCy~vpb7-3F^rK1MpdHr2DCJtrYMGO65>pAA^{|5b6S2_vc}# zNbHv!7|W8?Btp&sjDQ-zsHjX%hzD>2j1GXEy>5=XN^UCS(YEhnUWB{3Ec7>QJJ}qu-$#MA-^CZ6?uVA_(y0ea(nNbJOt^| zpX#++#E0syRgK?_B^FWWw0uP2Sd(Q}=U-b?Xv(gl=9r9QavK(-RQ;Bh z)=QMRf=Rr(cU&6Y`Yf$@oU6G_J9)2;F ze>ALnG_M`~30v(3$RG_-xs8un4&{^dz`xwIms=WGrU;4`k9EbK4jQXFwmV;^X_C5h z)X$4eoXYqBB69o6-f?e@_E9As$fw%31p>jHTIRy&;)@9KHt-PFXqoZ%)GX@SR%bt( zMO8D5lC{QC&AwXmEJa|IIn(I((t$HG7$!D!#vj@?$EH#i8_ReWcj8B&^RjcyJK=!L zDm0DdHkloemt=B_5Qg1@5>ULU*uF^o;wFTt1+dG+HX!|ciSWL?cSRoVZ2;WG-9{bT zEvhjCH=T_(SYshaBn)Zcr@m7$SU4 zmN6Q00$m6uLxdTFFh!=1N^{15k81`FC$V_ApfmvAZn_wp=R=`bHhm(;QEdS{)MK!FvG&r zB#IS}y)haxqnmbqzV>LA2lU0nm9>#k<4^+>3kMdco@Iw-4ZR(JkCv+(-M$zO^iZz* zmmu@>xB)n{w6oy)&Q5PQ+}SzY7%`Yoh7a^YJW^Yn*V|a`FWOeF&D}0e-W#>CJf@w* zNA$C<&10$sMf>(&>J;Wok)u`gC?(0|D(<1o(LK523Mc5vg*|*J3eU_v&TET9!l>OL bqh+6TzE(#@*30#JeYsx4sMeS2)kgVWHJo)n literal 0 HcmV?d00001 -- 2.20.1