autograph-utils 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,510 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """Top-level package for Python Autograph Utilities."""
4
+
5
+ __author__ = """Ethan Glasser-Camp"""
6
+ __email__ = "eglassercamp@mozilla.com"
7
+ __version__ = "0.1.0"
8
+
9
+
10
+ import base64
11
+ import binascii
12
+ import re
13
+ from abc import ABC
14
+ from datetime import datetime
15
+
16
+ import cryptography
17
+ import ecdsa.util
18
+ from cryptography import x509
19
+ from cryptography.hazmat.backends import default_backend
20
+ from cryptography.hazmat.primitives.asymmetric import ec as cryptography_ec
21
+ from cryptography.hazmat.primitives.asymmetric import padding
22
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
23
+ from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
24
+ from cryptography.hazmat.primitives.hashes import SHA256, SHA384
25
+ from cryptography.x509.oid import NameOID
26
+
27
+
28
+ class Cache(ABC):
29
+ """An interface for caching x5u validity checks."""
30
+
31
+ def get(self, url):
32
+ pass
33
+
34
+ def set(self, url, result):
35
+ pass
36
+
37
+
38
+ class MemoryCache:
39
+ """A simple Cache implementation that just uses a dictionary.
40
+
41
+ This cache does not expire data and can therefore grow without
42
+ bound. This may make it vulnerable to a denial-of-service attack.
43
+
44
+ """
45
+
46
+ def __init__(self):
47
+ self.data = {}
48
+
49
+ def get(self, url):
50
+ return self.data.get(url)
51
+
52
+ def set(self, url, result):
53
+ self.data[url] = result
54
+
55
+
56
+ Cache.register(MemoryCache)
57
+
58
+
59
+ class SubjectNameCheck(ABC):
60
+ """An interface for predicates that verify the subject name."""
61
+
62
+ def check(self, subject_name):
63
+ pass
64
+
65
+ def describe(self):
66
+ pass
67
+
68
+
69
+ class EndsWith:
70
+ def __init__(self, domain):
71
+ self.domain = domain
72
+
73
+ def check(self, subject_name):
74
+ return subject_name.endswith(self.domain)
75
+
76
+ def describe(self):
77
+ return f"ends with {self.domain!r}"
78
+
79
+
80
+ class ExactMatch:
81
+ def __init__(self, domain):
82
+ self.domain = domain
83
+
84
+ def check(self, subject_name):
85
+ return subject_name == self.domain
86
+
87
+ def describe(self):
88
+ return f"matches exactly {self.domain!r}"
89
+
90
+
91
+ BASE64_WRONG_LENGTH_RE = re.compile(
92
+ r"Invalid base64-encoded string: number of data characters \(\d+\) cannot "
93
+ r"be [123] more than a multiple of 4"
94
+ )
95
+
96
+
97
+ class BadCertificate(Exception):
98
+ def __init__(self, extra):
99
+ self.extra = extra
100
+
101
+ @property
102
+ def detail(self):
103
+ return f"Bad certificate: {self.extra}"
104
+
105
+
106
+ class CertificateParseError(BadCertificate):
107
+ def __init__(self, extra):
108
+ self.extra = extra
109
+
110
+ @property
111
+ def detail(self):
112
+ return f"Could not parse certificate: {self.extra}"
113
+
114
+
115
+ class CertificateNotYetValid(BadCertificate):
116
+ def __init__(self, not_before):
117
+ self.not_before = not_before
118
+
119
+ @property
120
+ def detail(self):
121
+ return f"Certificate is not valid until {self.not_before}"
122
+
123
+
124
+ class CertificateExpired(BadCertificate):
125
+ def __init__(self, not_after):
126
+ self.not_after = not_after
127
+
128
+ @property
129
+ def detail(self):
130
+ return f"Certificate expired on {self.not_after}"
131
+
132
+
133
+ class CertificateHasWrongRoot(BadCertificate):
134
+ def __init__(self, *, expected, actual):
135
+ self.expected = binascii.hexlify(expected).decode()
136
+ self.actual = binascii.hexlify(actual).decode()
137
+
138
+ @property
139
+ def detail(self):
140
+ return (
141
+ "Certificate is not based on expected root hash. "
142
+ f"Got {self.actual!r} expected {self.expected!r}"
143
+ )
144
+
145
+
146
+ class CertificateHasWrongSubject(BadCertificate):
147
+ def __init__(self, actual, check_description):
148
+ self.check_description = check_description
149
+ self.actual = actual
150
+
151
+ @property
152
+ def detail(self):
153
+ return (
154
+ f"Certificate does not have the expected subject. "
155
+ f"Got {self.actual!r}, checking for {self.check_description}"
156
+ )
157
+
158
+
159
+ class CertificateChainBroken(BadCertificate):
160
+ def __init__(self, previous_cert, next_cert):
161
+ self.previous_cert = previous_cert
162
+ self.next_cert = next_cert
163
+
164
+ @property
165
+ def detail(self):
166
+ return (
167
+ "Certificate chain is not continuous. "
168
+ f"Expected {self.previous_cert!r} to sign {self.next_cert!r}"
169
+ )
170
+
171
+
172
+ class CertificateUnsupportedKeyType(BadCertificate):
173
+ """An internal error indicating that support for some type of key is missing."""
174
+
175
+ def __init__(self, cert, key):
176
+ self.cert = cert
177
+ self.key = key
178
+
179
+ @property
180
+ def detail(self):
181
+ return f"Unknown public key type for {self.cert!r}: {self.key!r}"
182
+
183
+
184
+ class CertificateChainNameNotPermitted(BadCertificate):
185
+ def __init__(self, permitted_subtrees, current, next):
186
+ self.permitted_subtrees = permitted_subtrees
187
+ self.current = current
188
+ self.next = next
189
+
190
+ @property
191
+ def detail(self):
192
+ return (
193
+ f"Certificate name of {self.next!r} does not match the permitted names "
194
+ f"for {self.current!r}: {self.permitted_subtrees!r}"
195
+ )
196
+
197
+
198
+ class CertificateCannotSign(BadCertificate):
199
+ """For intermediate/root certificates that do not have the proper
200
+ metadata bits saying that they can be used to sign signatures.
201
+
202
+ """
203
+
204
+ def __init__(self, cert, extra):
205
+ self.cert = cert
206
+ self.extra = extra
207
+
208
+ @property
209
+ def detail(self):
210
+ return "Certificate cannot be used for signing " f"because {self.extra}: {self.cert!r}"
211
+
212
+
213
+ class CertificateLeafHasWrongKeyUsage(BadCertificate):
214
+ def __init__(self, cert, key_usage):
215
+ self.cert = cert
216
+ self.key_usage = key_usage
217
+
218
+ @property
219
+ def detail(self):
220
+ return (
221
+ f"Leaf certificate {self.cert!r} should have extended key usage of just "
222
+ f"Code Signing. Got {self.key_usage!r}"
223
+ )
224
+
225
+
226
+ class CertificateChainNameExcluded(BadCertificate):
227
+ def __init__(self, excluded_subtrees, current, next):
228
+ self.excluded_subtrees = excluded_subtrees
229
+ self.current = current
230
+ self.next = next
231
+
232
+ @property
233
+ def detail(self):
234
+ return (
235
+ f"Certificate name of {self.next!r} matches the excluded names "
236
+ f"for {self.current!r}: {self.excluded_subtrees!r}"
237
+ )
238
+
239
+
240
+ class BadSignature(Exception):
241
+ detail = "Unknown signature problem"
242
+
243
+
244
+ class SignatureDoesNotMatch(BadSignature):
245
+ detail = "Signature does not correspond to this data"
246
+
247
+
248
+ class WrongSignatureSize(BadSignature):
249
+ detail = "Signature is not the right number of bytes"
250
+
251
+
252
+ class SignatureVerifier:
253
+ """A utility to verify the provenance of data.
254
+
255
+ This class lets you verify that data is signed by a collection of
256
+ certificates that chains back up to some well-known root
257
+ hash. Certificate chains are identified by x5u. x5us are assumed
258
+ to be static and so can be cached to save network traffic.
259
+
260
+ :params ClientSession session: An aiohttp session, used to retrieve x5us.
261
+ :params Cache cache: A cache used to store results for x5u verification.
262
+ :params bytes root_hash: The expected hash for the first
263
+ certificate in a chain. This should not be encoded in any
264
+ way. Hashes can be decoded using decode_mozilla_hash.
265
+ :params SubjectNameCheck subject_name_check: Predicate to use to
266
+ validate cert subject names. Defaults to
267
+ EndsWith(".content-signature.mozilla.org").
268
+
269
+ """
270
+
271
+ def __init__(self, session, cache, root_hash, subject_name_check=None):
272
+ self.session = session
273
+ self.cache = cache
274
+ self.root_hash = root_hash
275
+ self.subject_name_check = subject_name_check or EndsWith(".content-signature.mozilla.org")
276
+
277
+ algorithm = cryptography_ec.ECDSA(SHA384())
278
+
279
+ async def verify(self, data, signature, x5u):
280
+ """Verify that the data is signed by certs that chain up to the root hash.
281
+
282
+ Returns True if the signature checks out and raises an
283
+ exception otherwise.
284
+
285
+ :param bytes data: Data that was signed.
286
+ :param signature: Signature in Autograph format (described in
287
+ Autograph docs as "DL/ECSSA representation of the R and S
288
+ values (IEEE Std 1363-2000)"). This can be bytes or str.
289
+ :param str x5u: URL of a certificate chain which was allegedly
290
+ used to sign the data.
291
+ :raises: BadCertificate if the certificate is bad;
292
+ BadSignature if signature verification fails
293
+
294
+ """
295
+ cert = await self.verify_x5u(x5u)
296
+ # Decode signature into the (r, s) components
297
+ try:
298
+ signature = base64.urlsafe_b64decode(signature)
299
+ except binascii.Error as e:
300
+ if BASE64_WRONG_LENGTH_RE.match(e.args[0]):
301
+ raise WrongSignatureSize("Base64 encoded signature was not a multiple of 4")
302
+ raise
303
+
304
+ try:
305
+ r, s = ecdsa.util.sigdecode_string(signature, order=ecdsa.curves.NIST384p.order)
306
+ except ecdsa.util.MalformedSignature:
307
+ raise WrongSignatureSize()
308
+
309
+ # Encode as DER for cryptography
310
+ signature = encode_dss_signature(r, s)
311
+
312
+ # Content signature implicitly adds a prefix to signed data
313
+ data = b"Content-Signature:\x00" + data
314
+
315
+ try:
316
+ cert.public_key().verify(signature, data, self.algorithm)
317
+ except cryptography.exceptions.InvalidSignature:
318
+ raise SignatureDoesNotMatch()
319
+
320
+ return True
321
+
322
+ async def verify_x5u(self, url):
323
+ cached = self.cache.get(url)
324
+ if cached:
325
+ return cached
326
+
327
+ async with self.session.get(url) as response:
328
+ response.raise_for_status()
329
+ content = await response.read()
330
+ pems = split_pem(content)
331
+ certs = [x509.load_pem_x509_certificate(pem, backend=default_backend()) for pem in pems]
332
+
333
+ now = _now()
334
+ for cert in certs:
335
+ if cert.not_valid_before > cert.not_valid_after:
336
+ raise BadCertificate(
337
+ f"not_before ({cert.not_valid_before}) after "
338
+ f"not_after ({cert.not_valid_after})"
339
+ )
340
+ if now < cert.not_valid_before:
341
+ raise CertificateNotYetValid(cert.not_valid_before)
342
+ if now > cert.not_valid_after:
343
+ raise CertificateExpired(cert.not_valid_after)
344
+
345
+ # Verify chain of trust.
346
+ chain = certs[::-1]
347
+ root_hash = chain[0].fingerprint(SHA256())
348
+ if root_hash != self.root_hash:
349
+ raise CertificateHasWrongRoot(expected=self.root_hash, actual=root_hash)
350
+
351
+ current_cert = chain[0]
352
+ for next_cert in chain[1:]:
353
+ self._check_can_sign_other_certs(current_cert)
354
+ self._verify_cert_link(current_cert, next_cert)
355
+ self._check_name_constraints(current_cert, next_cert)
356
+
357
+ current_cert = next_cert
358
+
359
+ leaf_subject_name = certs[0].subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
360
+ if not self.subject_name_check.check(leaf_subject_name):
361
+ raise CertificateHasWrongSubject(
362
+ leaf_subject_name, check_description=self.subject_name_check.describe()
363
+ )
364
+
365
+ code_signing = cryptography.x509.oid.ExtendedKeyUsageOID.CODE_SIGNING
366
+ extended_key_usage = (
367
+ certs[0].extensions.get_extension_for_class(cryptography.x509.ExtendedKeyUsage).value
368
+ )
369
+ if list(extended_key_usage) != [code_signing]:
370
+ raise CertificateLeafHasWrongKeyUsage(certs[0], extended_key_usage)
371
+
372
+ res = certs[0]
373
+ self.cache.set(url, res)
374
+ return res
375
+
376
+ def _verify_cert_link(self, current_cert, next_cert):
377
+ """Verify a single link in a cert chain."""
378
+ key = current_cert.public_key()
379
+ if isinstance(key, RSAPublicKey):
380
+ try:
381
+ key.verify(
382
+ next_cert.signature,
383
+ next_cert.tbs_certificate_bytes,
384
+ padding.PKCS1v15(),
385
+ next_cert.signature_hash_algorithm,
386
+ )
387
+ except cryptography.exceptions.InvalidSignature:
388
+ raise CertificateChainBroken(current_cert, next_cert)
389
+ elif isinstance(key, cryptography_ec.EllipticCurvePublicKey):
390
+ try:
391
+ key.verify(
392
+ next_cert.signature,
393
+ next_cert.tbs_certificate_bytes,
394
+ cryptography_ec.ECDSA(next_cert.signature_hash_algorithm),
395
+ )
396
+ except cryptography.exceptions.InvalidSignature:
397
+ raise CertificateChainBroken(current_cert, next_cert)
398
+ else:
399
+ raise CertificateUnsupportedKeyType(current_cert, key)
400
+
401
+ def _check_name_constraints(self, current_cert, next_cert):
402
+ try:
403
+ nc = current_cert.extensions.get_extension_for_class(
404
+ cryptography.x509.NameConstraints
405
+ ).value
406
+ except x509.ExtensionNotFound:
407
+ # No name constraints. This cert is therefore OK to sign
408
+ # any name whatsoever.
409
+ return
410
+
411
+ name = next_cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
412
+ if nc.permitted_subtrees:
413
+ for constraint in nc.permitted_subtrees:
414
+ if _name_constraint_matches(name, constraint):
415
+ break
416
+ else:
417
+ raise CertificateChainNameNotPermitted(
418
+ nc.permitted_subtrees, current=current_cert, next=next_cert
419
+ )
420
+
421
+ excluded_subtrees = nc.excluded_subtrees or []
422
+
423
+ for constraint in excluded_subtrees:
424
+ if _name_constraint_matches(name, constraint):
425
+ raise CertificateChainNameExcluded(
426
+ nc.excluded_subtrees, current=current_cert, next=next_cert
427
+ )
428
+
429
+ def _check_can_sign_other_certs(self, cert):
430
+ basic = cert.extensions.get_extension_for_class(cryptography.x509.BasicConstraints).value
431
+ if not basic.ca:
432
+ raise CertificateCannotSign(cert, "ca is false")
433
+
434
+ usage = cert.extensions.get_extension_for_class(cryptography.x509.KeyUsage).value
435
+ usage_is_ok = usage.key_cert_sign and usage.crl_sign
436
+ if not usage_is_ok:
437
+ raise CertificateCannotSign(cert, "key usage is incomplete")
438
+
439
+
440
+ def _name_constraint_matches(hostname, name_constraint):
441
+ """Check if a name matches a constraint.
442
+
443
+ Taken from
444
+ https://github.com/alex/x509-validator/blob/master/validator.py.
445
+
446
+ """
447
+ if not isinstance(name_constraint, x509.DNSName):
448
+ return False
449
+ constraint_hostname = name_constraint.value
450
+
451
+ if constraint_hostname.startswith("."):
452
+ return hostname.endswith(constraint_hostname)
453
+ else:
454
+ return hostname == constraint_hostname or hostname.endswith("." + constraint_hostname)
455
+
456
+
457
+ def split_pem(s):
458
+ """Split a string containing many ASCII-armored PEM structures.
459
+
460
+ No validation is performed on the PEM structures (even to the
461
+ point of verifying that the BEGIN lines match the END lines).
462
+
463
+ :param bytes s: bytes containing a list of PEM-encoded things
464
+ :returns: List of bytes, each representing a single PEM-encoded thing
465
+ """
466
+ out = []
467
+ acc = []
468
+ state = "PRE"
469
+ for line in s.split(b"\n"):
470
+ if state == "PRE" and line.startswith(b"-----BEGIN "):
471
+ acc.append(line)
472
+ state = "BODY_OR_META"
473
+ elif state == "PRE" and not line:
474
+ pass
475
+ elif state == "BODY_OR_META" and b":" in line:
476
+ state = "META"
477
+ elif state == "BODY" and line.startswith(b"-----END "):
478
+ acc.append(line)
479
+ out.append(b"\n".join(acc))
480
+ acc = []
481
+ state = "PRE"
482
+ elif state == "META" and not line:
483
+ state = "BODY"
484
+ elif state == "BODY" or state == "BODY_OR_META":
485
+ acc.append(line)
486
+ state = "BODY"
487
+ else:
488
+ raise CertificateParseError(f'Unexpected input "{line}" in state "{state}"')
489
+
490
+ if acc:
491
+ raise CertificateParseError(f"Unexpected end of input. Leftover: {acc}")
492
+
493
+ return out
494
+
495
+
496
+ def decode_mozilla_hash(s):
497
+ """Convert a hash from pseudo-base16 colon-separated format.
498
+
499
+ >>> decode_mozilla_hash('4C:35:B1:C3')
500
+ b'\x4c\x35\xb1\xc3')
501
+ """
502
+ return bytes.fromhex(s.replace(":", " "))
503
+
504
+
505
+ def _now():
506
+ """Mockable function to get "now".
507
+
508
+ :returns: naive datetime representing a UTC timestamp
509
+ """
510
+ return datetime.utcnow()
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from autograph_utils.main import main
4
+
5
+
6
+ sys.exit(main())
@@ -0,0 +1,13 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """Console script for autograph_utils."""
4
+
5
+ import click
6
+
7
+
8
+ @click.command()
9
+ def main(args=None):
10
+ """Console script for autograph_utils."""
11
+ click.echo("Replace this message by putting your code into autograph_utils.cli.main")
12
+ click.echo("See click documentation at https://click.palletsprojects.com/")
13
+ return 0
@@ -0,0 +1,15 @@
1
+ Apache Software License 2.0
2
+
3
+ Copyright (c) 2019, Ethan Glasser-Camp
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.1
2
+ Name: autograph-utils
3
+ Version: 0.2.0
4
+ Summary: autograph-utils is a tool to configure a kinto server from an YAML file.
5
+ Author-email: Mozilla Services <dev-webdev@lists.mozilla.org>
6
+ License: Apache Software License 2.0
7
+
8
+ Copyright (c) 2019, Ethan Glasser-Camp
9
+
10
+ Licensed under the Apache License, Version 2.0 (the "License");
11
+ you may not use this file except in compliance with the License.
12
+ You may obtain a copy of the License at
13
+
14
+ http://www.apache.org/licenses/LICENSE-2.0
15
+
16
+ Unless required by applicable law or agreed to in writing, software
17
+ distributed under the License is distributed on an "AS IS" BASIS,
18
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19
+ See the License for the specific language governing permissions and
20
+ limitations under the License.
21
+
22
+ Project-URL: Repository, https://github.com/mozilla-services/autograph-utils
23
+ Keywords: autograph
24
+ Classifier: Programming Language :: Python
25
+ Classifier: Programming Language :: Python :: 3
26
+ Classifier: Intended Audience :: Developers
27
+ Classifier: License :: OSI Approved :: Apache Software License
28
+ Classifier: Natural Language :: English
29
+ Description-Content-Type: text/x-rst
30
+ License-File: LICENSE
31
+ Requires-Dist: Click >=7.0
32
+ Requires-Dist: aiohttp >=3.6
33
+ Requires-Dist: cryptography >=2.8
34
+ Requires-Dist: ecdsa >=0.13
35
+ Provides-Extra: dev
36
+ Requires-Dist: ruff ; extra == 'dev'
37
+ Requires-Dist: pytest ; extra == 'dev'
38
+ Requires-Dist: pytest-cache ; extra == 'dev'
39
+ Requires-Dist: pytest-aiohttp ; extra == 'dev'
40
+ Requires-Dist: pytest-cov ; extra == 'dev'
41
+ Requires-Dist: aioresponses ; extra == 'dev'
42
+
43
+ ==========================
44
+ Python Autograph Utilities
45
+ ==========================
46
+
47
+ |pypi| |ci| |coverage|
48
+
49
+ .. |pypi| image:: https://img.shields.io/pypi/v/autograph-utils.svg
50
+ :target: https://pypi.python.org/pypi/autograph-utils
51
+ .. |ci| image:: https://img.shields.io/github/actions/workflow/status/mozilla-services/python-autograph-utils/test.yml?branch=main
52
+ :target: https://github.com/mozilla-services/python-autograph-utils/actions
53
+ .. |coverage| image:: https://coveralls.io/repos/github/mozilla-services/python-autograph-utils/badge.svg?branch=main
54
+ :target: https://coveralls.io/github/mozilla-services/python-autograph-utils?branch=main
55
+
56
+ A library to simplify use of Autograph
57
+
58
+
59
+ * Free software: Apache Software License 2.0
60
+ * Documentation: https://python-autograph-utils.readthedocs.io.
61
+
62
+
63
+ Features
64
+ --------
65
+
66
+ SignatureVerifier
67
+ =================
68
+
69
+ The canonical implementation of certificate chain validation. Although
70
+ some other implementations seem to exist (such as
71
+ https://github.com/river2sea/X509Validation,
72
+ https://github.com/alex/x509-validator, and
73
+ https://github.com/openstack/cursive), all are marked as
74
+ pre-production and/or needing work, so just do it ourselves.
75
+
76
+ Credits
77
+ -------
78
+
79
+ This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template.
80
+
81
+ .. _Cookiecutter: https://github.com/audreyr/cookiecutter
82
+ .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage
@@ -0,0 +1,9 @@
1
+ autograph_utils/__init__.py,sha256=luFKvmlgXRjkacIg7GMml6VPmMxbbvlRzXbHIlexeyw,16361
2
+ autograph_utils/__main__.py,sha256=8iIKqBCCSGGQeCqXT1ICe27lfjzwu9SOIvQPH6CHqy8,69
3
+ autograph_utils/main.py,sha256=MZ8ZlbKsXlk4CNYXutDTHyMcDwFcPze_Pc4tt3ZzM4E,350
4
+ autograph_utils-0.2.0.dist-info/LICENSE,sha256=LNVmJ5dLg64uFqoRBlph_037cXGhWkW9SjQqNTHZROQ,589
5
+ autograph_utils-0.2.0.dist-info/METADATA,sha256=XVIkDLV0Rd4GLgf7_vJV2-pXg8apXzUMN6vA1gW8G5s,3172
6
+ autograph_utils-0.2.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
7
+ autograph_utils-0.2.0.dist-info/entry_points.txt,sha256=FW66_7S93DJRjhc8fDtXL7yfsulOVLDZtkEaPOxqPOs,61
8
+ autograph_utils-0.2.0.dist-info/top_level.txt,sha256=_ZMBWDkQc1fqG36jSbXjaSW2tOUwRITIWHbsmdNCSLY,16
9
+ autograph_utils-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ autograph_utils = autograph_utils.cli:main
@@ -0,0 +1 @@
1
+ autograph_utils