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

tls-alpn-01 challenge support #28

Closed
wants to merge 11 commits into from
Closed

tls-alpn-01 challenge support #28

wants to merge 11 commits into from

Conversation

dholth
Copy link

@dholth dholth commented Mar 24, 2019

This is the simplest possible acme-tls/1 responder for txsni.

To use, get the dehydrated shell script, configure ~/etc/dehydrated/ with config (set BASEDIR) and domains.txt (list of domains), run authbind twist web --port acmesni:~/etc/dehydrated:tcp6:443, and run dehydrated -c --force in the ~/etc/dehydrated/ folder. For testing it's a good idea to use a separate -staging directory and config to avoid running against letsencrypt rate limits.

It also has a couple of unicode fixes.

Tested in pypy 3.6.1 version 7.0.0-alpha0

txsni/snimap.py Show resolved Hide resolved
txsni/snimap.py Show resolved Hide resolved
txsni/snimap.py Show resolved Hide resolved
txsni/snimap.py Show resolved Hide resolved
txsni/parser.py Outdated Show resolved Hide resolved
@codecov-io
Copy link

codecov-io commented Mar 24, 2019

Codecov Report

Merging #28 into master will increase coverage by 1.69%.
The diff coverage is 96.83%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master      #28      +/-   ##
==========================================
+ Coverage      95%   96.69%   +1.69%     
==========================================
  Files           6        7       +1     
  Lines         400      545     +145     
  Branches       28       45      +17     
==========================================
+ Hits          380      527     +147     
+ Misses         12       11       -1     
+ Partials        8        7       -1
Impacted Files Coverage Δ
txsni/parser.py 100% <100%> (ø) ⬆️
txsni/test/certs/cert_builder.py 97.97% <100%> (+0.68%) ⬆️
txsni/certmaps.py 91.66% <91.66%> (ø)
txsni/snimap.py 93.33% <92.3%> (+4.33%) ⬆️
txsni/test/test_txsni.py 98.26% <98.48%> (+0.04%) ⬆️
txsni/only_noticed_pypi_pem_after_i_wrote_this.py 93.54% <0%> (+6.45%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 5014c14...f016940. Read the comment docs.

txsni/snimap.py Show resolved Hide resolved
@dholth
Copy link
Author

dholth commented Mar 24, 2019

Fixes #20, #25, #27

Copy link
Owner

@glyph glyph left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some questions before I'd really feel comfortable landing this; I think I might just not have a full enough picture of what you're trying to do. And possibly the work here should be split across a couple of different PRs. But overall; very exciting.

txsni/snimap.py Show resolved Hide resolved
txsni/dehydrated.py Outdated Show resolved Hide resolved
setup.py Outdated Show resolved Hide resolved
txsni/snimap.py Outdated Show resolved Hide resolved
txsni/snimap.py Show resolved Hide resolved
txsni/snimap.py Outdated Show resolved Hide resolved
txsni/dehydrated.py Outdated Show resolved Hide resolved
@dholth
Copy link
Author

dholth commented Mar 24, 2019

It might be helpful to think of this code as only doing the same job as http port 80 for the http-01 http://<YOUR_DOMAIN>/.well-known/acme-challenge/ challenge type. Most of the ACME protocol is elsewhere, and this is quite useful without txacme.

txacme would build on top of txsni by pausing an incoming request, putting the special acme certificate in SNIMap.acme_mapping, having letsencrypt fetch that certificate, and on success install the new certificate to continue with the original request. Everything letsencrypt-related except the challenge is the same as what txacme does now.

I've tried to make the certificate loaders more generic. They probably work with more than just dehydrated. They are like HostDirectoryMap but they load the certificate from two files. Do they need to be underscored if HostDirectoryMap is not? It was not clear quite which code that comment was about.

@dholth
Copy link
Author

dholth commented Apr 1, 2019

I've improved the test coverage and lo and behold found bugs, like "empty dict is falsy".

It looks like the CI is using a pretty old version of pypy. Tests pass over here on python 2 and 3 versions of pypy 7.

@glyph
Copy link
Owner

glyph commented Jul 22, 2019

Oops. I tried turning on codacy as an experiment, it's not supposed to be gating PRs like this.

Copy link
Owner

@glyph glyph left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs a slightly better interface that txacme would actually want to consume, but I am very much encouraged by this PR!

certificateOptionsFromPileOfPEM
)

class PerHostnameDirectoryMap(object):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you submit a separate PR which just adds these different sorts of mappings so we can reduce the line count on this one?

raise KeyError("no pem files for " + hostname.decode('charmap'))


class PerHostnameFilesMap(object):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely like the idea here; certbot compatibility would be nice!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Especially after I got some requests to dingoskidneys.com with a hostname I knew nothing about, because there is an old DNS entry for my IP address, I like the "separate client, a bit of configuration, and web server integration" strategy.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like what you really want here is better error handling in txsni or txacme?

IPlugin)
class AcmeSNIParser(object):
"""
Expects a ${BASEDIR} with certs/ and alpn-certs/ subdirectories.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a weird addition just for working with this one specific certificate provisioning flow. I'm not totally opposed to including it but it seems like it should be its own addition.

"""
Look for acme in the first packet only.
"""
self._acme_tls_1 = _detect_acme(buf)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you do a slightly more principled parsing of the ClientHello? It would be nice to deliver this to the next layer up as a string rather than a flag. For example, acme-tls/2 might be interesting one day.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have documentation for the client hello structure? I found and lost it.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future reference the easy way to get an idea of the ClientHello format is to capture one in WireShark. It cross highlights the hex dump with the named protocol elements.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check this out.

https://gist.github.com/dholth/b766f20cdab26cee082f3f58d7c015d7

#!/usr/bin/env python
# Extremely Principled TLS v1.2 ClientHello parser for ALPN extensions

import struct
import binascii


_int16 = struct.Struct(">H")


def int16(b):
    """
    Return first two bytes of b as an unsigned integer.
    """
    return _int16.unpack(b[:2])[0]


def take(data, count):
    prefix = data[:count]
    data = data[count:]
    return prefix, data


# Handshake, 3, 1 (TLS 1.0)
HEADER = b"\x16\x03\x01"
TYPE_ALPN = b"\x00\x10"


def parseHello(data):
    """
    Parse TLS 1.2 ClientHello from data, return the extensions as binary data
    or None if not found.

    Likely to raise struct.error or IndexError on error.
    """
    # if not data.startswith(HEADER):
    #     return
    header, data = take(data, 7)
    messageId, major, minor, l1, l2 = struct.unpack(">BBBHH", header)
    print(messageId, major, minor, l1, l2)
    # if not data.startswith(b"\x01"):  # ClientHello
    #     return
    header2, data = take(data, 4)
    print(struct.unpack(">HBB", header2))  # inner length; 3; 3
    # random
    random, data = take(data, 32)
    # session identifier
    slen, data = take(data, 1)
    slen = slen[0]
    session, data = take(data, slen)
    # ciphers list
    clen, data = take(data, 2)
    clen = int16(clen)
    ciphers, data = take(data, clen)
    # compression methods (should always be an array of length 1, with one 0 element)
    compression_length, data = take(data, 1)
    compression_methods, data = take(data, compression_length[0])
    # extensions
    extlen, data = take(data, 2)
    extensions, data = take(data, int16(extlen))
    return extensions


def parseExtensions(data):
    """
    Yield (type, body) for TLS extensions in data, as binary data.
    """
    while data:
        type, data = take(data, 2)
        length, data = take(data, 2)
        body, data = take(data, int16(length))
        yield (type, body)


def parseAlpn(body):
    """
    Parse array of Pascal strings, ignore a 16-bit length header.
    """
    length, body = take(body, 2)
    while body:
        protocol, body = take(body[1:], body[0])
        yield protocol


if __name__ == "__main__":
    sample_hello = b"FgMBAgABAAH8AwN3t6WJKcsKcWo+roqQX7Nuc8SYCUAKTIkINuDoJm4ooiDRiC2236q0JY/NewWV9KcViEzk7S03gwwUSioSOKbOcAAkEwETAxMCwCvAL8ypzKjALMAwwArACcATwBQAMwA5AC8ANQAKAQABjwAAAA4ADAAACWxvY2FsaG9zdAAXAAD/AQABAAAKAA4ADAAdABcAGAAZAQABAQALAAIBAAAjAAAAEAAOAAwCaDIIaHR0cC8xLjEABQAFAQAAAAAAMwBrAGkAHQAgcqzbr+1AYblh6qcR+qvjokWhIpbChkaqpXuDY9uHhVoAFwBBBAq/uAsPt0n3lc9MGArs6RqLoQE+1eWkstNR0zPjxlQcqGSD+1mKyvSCGEwU0DCZAEFEvhnj5YxSyqcAFODwnp4AKwAJCAMEAwMDAgMBAA0AGAAWBAMFAwYDCAQIBQgGBAEFAQYBAgMCAQAtAAIBAQAcAAJAAQAVAJUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="

    import base64
    import pprint

    data = bytearray(base64.b64decode(sample_hello))
    extensions = parseHello(data)
    for (type, body) in parseExtensions(extensions):
        if type == TYPE_ALPN:
            pprint.pprint(list(parseAlpn(body)))

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking great! Thank you for doing this!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking a break from being successful to review this.


def alpn_callback(connection, protocols):
"""wrapped alpn_select_callback"""
return self._factory.selectAlpn(lambda: cb(connection, protocols), connection, protocols)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually helpful given the problems within OpenSSL?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The wrapped function says "yes, I will speak acme-tls/1" when the client has asked for it. We can do that without including acme-tls/1 in the list used by the default callback.

@@ -28,6 +28,13 @@
HTTP2BIN_CERT_PATH = os.path.join(CERT_DIR, 'http2bin.org.pem')
HTTP2BIN_KEY_PATH = os.path.join(CERT_DIR, 'http2bin.org.key')

# acme style (not concatenated)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be nice to have these put together in an object that actually represents the "acme style" in one place that isn't in the tests

@glyph glyph changed the title acme support tls-alpn-01 challenge support Aug 4, 2019
@glyph
Copy link
Owner

glyph commented Aug 10, 2019

Thanks for your responses!

@mithrandi
Copy link
Contributor

What's the status of this branch?

@dholth
Copy link
Author

dholth commented Feb 27, 2020 via email

@glyph glyph closed this Aug 1, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants