Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add expires option #291

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 17 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,20 @@ def test_trustme_cli_quiet(capsys, tmpdir):

captured = capsys.readouterr()
assert not captured.out


def test_trustme_cli_expires(tmpdir):
with tmpdir.as_cwd():
with pytest.raises(ValueError, match="expected timespan format"):
main(argv=["--expires", "something"])

assert tmpdir.join("server.key").check(exists=0)
assert tmpdir.join("server.pem").check(exists=0)
assert tmpdir.join("client.pem").check(exists=0)

with tmpdir.as_cwd():
main(argv=["--expires", "1m"])

assert tmpdir.join("server.key").check(exists=1)
assert tmpdir.join("server.pem").check(exists=1)
assert tmpdir.join("client.pem").check(exists=1)
54 changes: 54 additions & 0 deletions tests/test_trustme.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,60 @@ def test_issue_cert_custom_names():
})


def test_timespan_to_timedelta():
ts_to_td = trustme.timespan_to_timedelta

with pytest.raises(ValueError):
ts_to_td("")

with pytest.raises(ValueError):
ts_to_td("7 days")

start_date = datetime.datetime(2000, 1, 1, 0, 0, 0)

cases = {
"hour": start_date + ts_to_td("1H"),
"minute": start_date + ts_to_td("1M"),
"second": start_date + ts_to_td("1S"),
"day": start_date + ts_to_td("1d"),
"week": start_date + ts_to_td("1w"),
"month": start_date + ts_to_td("1m"),
"year": start_date + ts_to_td("1y"),
}

for c in cases:
assert isinstance(cases[c], datetime.datetime)

assert cases["hour"] == datetime.datetime(2000, 1, 1, 1, 0, 0)
assert cases["minute"] == datetime.datetime(2000, 1, 1, 0, 1, 0)
assert cases["second"] == datetime.datetime(2000, 1, 1, 0, 0, 1)
assert cases["day"] == datetime.datetime(2000, 1, 2, 0, 0, 0)
assert cases["week"] == datetime.datetime(2000, 1, 8, 0, 0, 0)
assert cases["month"] == datetime.datetime(2000, 1, 31, 0, 0, 0)
assert cases["year"] == datetime.datetime(2000, 12, 31, 0, 0, 0)


def test_issue_cert_custom_not_after():
now = datetime.datetime.now()
expires = now + trustme.timespan_to_timedelta("7d")
ca = CA()

leaf_cert = ca.issue_cert(
u'example.org',
organization_name=u'python-trio',
organization_unit_name=u'trustme',
not_after=expires,
)

cert = x509.load_pem_x509_certificate(
leaf_cert.cert_chain_pems[0].bytes(),
default_backend(),
)

for t in ["year", "month", "day", "hour", "minute", "second"]:
assert getattr(cert.not_valid_after, t) == getattr(expires, t)


def test_intermediate():
ca = CA()
ca_cert = x509.load_pem_x509_certificate(
Expand Down
57 changes: 55 additions & 2 deletions trustme/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-

import datetime
import re
import ssl
from base64 import urlsafe_b64encode
from tempfile import NamedTemporaryFile
Expand Down Expand Up @@ -37,6 +38,9 @@
# by default reject any keys with <2048 bits (see #45).
_KEY_SIZE = 2048

# Default certificate expiry date
DEFAULT_NOT_AFTER = datetime.datetime(2038, 1, 1)


def _name(name, organization_name=None, common_name=None):
name_pieces = [
Expand All @@ -61,7 +65,9 @@ def _smells_like_pyopenssl(ctx):
return getattr(ctx, "__module__", "").startswith("OpenSSL")


def _cert_builder_common(subject, issuer, public_key):
def _cert_builder_common(subject, issuer, public_key, not_after=None):
if not_after is None:
not_after = DEFAULT_NOT_AFTER
return (
x509.CertificateBuilder()
.subject_name(subject)
Expand All @@ -74,7 +80,7 @@ def _cert_builder_common(subject, issuer, public_key):
# Some versions of cryptography on 32-bit platforms fail if you give
# them dates after ~2038-01-19:
# https://github.com/pyca/cryptography/pull/4658
.not_valid_after(datetime.datetime(2038, 1, 1))
.not_valid_after(not_after)
.serial_number(x509.random_serial_number())
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(public_key),
Expand Down Expand Up @@ -128,6 +134,47 @@ def _identity_string_to_x509(identity):
return x509.DNSName(alabel)


def timespan_to_timedelta(value):
# Given a timespan as a string in the format of Dt, where D is some
# number and t is a letter representing some span of time:
# H - hour
# M - minute
# S - second
# d - day
# w - week
# m - month
# y - year
#
# ...returns a datetime.timedelta representing that span of time.
#
# Examples: 7d = 7 days, 9m = 9 months, 2H = 2 hours
timespans = {
# key -> type, multiplier
"H": ["hours", 1],
"M": ["minutes", 1],
"S": ["seconds", 1],
"d": ["days", 1],
"w": ["weeks", 1],
"m": ["days", 30],
"y": ["days", 365],
}
check_pattern = "^(\d+)([{spans}])$".format(
spans="".join(timespans.keys())
)
m = re.match(check_pattern, value)
if m is None:
raise ValueError(
(
"Value '{}' is not in the expected timespan format "
"of Dt, where D is a number and t is one of 'H', "
"'M', 'S', 'd', 'w', 'm', or 'y'"
).format(value)
)
duration, span = m.groups()
delta_kwargs = {timespans[span][0]: timespans[span][1] * int(duration)}
return datetime.timedelta(**delta_kwargs)


class Blob(object):
"""A convenience wrapper for a blob of bytes.

Expand Down Expand Up @@ -340,13 +387,18 @@ def issue_cert(self, *identities, **kwargs):
attribute on the certificate. By default, a random one will be
generated.

not_after: Sets when the certificate will expire (Not After) in
datetime format. If set to None, the certificate's expiry will
default to Jan 1, 2038

Returns:
LeafCert: the newly-generated certificate.

"""
common_name = kwargs.pop("common_name", None)
organization_name = kwargs.pop("organization_name", None)
organization_unit_name = kwargs.pop("organization_unit_name", None)
not_after = kwargs.pop("not_after", None)
if kwargs:
raise TypeError("unrecognized keyword arguments {}".format(kwargs))

Expand Down Expand Up @@ -382,6 +434,7 @@ def issue_cert(self, *identities, **kwargs):
),
self._certificate.subject,
key.public_key(),
not_after=not_after,
)
.add_extension(
x509.BasicConstraints(ca=False, path_length=None),
Expand Down
26 changes: 25 additions & 1 deletion trustme/_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-

import argparse
import datetime
import os
import trustme
import sys
Expand Down Expand Up @@ -36,6 +37,17 @@ def main(argv=None):
default=None,
help="Also sets the deprecated 'commonName' field (only for the first identity passed).",
)
parser.add_argument(
"-e",
"--expires",
default=None,
help=(
"Specify how long from now the client certificate will expire. This is given in the "
"format, 'Dt', where D is some number, and t is a letter representing some span of time: "
"H (hours), M (minutes), S (seconds), d (days), w (weeks), m (months), y (years). "
"Examples: 7M (7 minutes), 1m (1 month), 2h (2 hours), 1y (1 year)"
),
)
parser.add_argument(
"-q",
"--quiet",
Expand All @@ -47,6 +59,7 @@ def main(argv=None):
cert_dir = args.dir
identities = [unicode(identity) for identity in args.identities]
common_name = unicode(args.common_name[0]) if args.common_name else None
not_after = time_from_now(args.expires)
quiet = args.quiet

if not os.path.isdir(cert_dir):
Expand All @@ -56,7 +69,7 @@ def main(argv=None):

# Generate the CA certificate
ca = trustme.CA()
cert = ca.issue_cert(*identities, common_name=common_name)
cert = ca.issue_cert(*identities, common_name=common_name, not_after=not_after)

# Write the certificate and private key the server should use
server_key = os.path.join(cert_dir, "server.key")
Expand All @@ -79,3 +92,14 @@ def main(argv=None):
print(" key={}".format(server_key))
print("Configure your client to use the following files:")
print(" cert={}".format(client_cert))
if not_after is not None:
print("Client cert will expire at: {}".format(not_after))


def time_from_now(value):
if value is None:
return None

now = datetime.datetime.now()
delta = trustme.timespan_to_timedelta(value)
return now + delta