如何处理生成签名 URL 以通过 CloudFront 访问私有内容的性能?

How to cope with the performance of generating signed URLs for accessing private content via CloudFront?

AWS S3 和 CloudFront 的一个常见用例是提供私有内容。常见的解决方案是使用签名的 CloudFront URLs 访问使用 S3 存储的私人文件。

然而,这些 URL 的生成是有代价的:使用私钥计算任何给定 URL 的 RSA 签名。对于 Python(或 boto,AWS 的 Python SDK),rsa (https://pypi.python.org/pypi/rsa) 库用于此任务。在我 2014 年底的 MBP 上,使用 2048 位密钥每次计算大约需要 25 毫秒。

此成本可能会影响使用此方法授权通过 CloudFront 访问私有内容的应用程序的可扩展性。想象一下,多个客户端以 25~30 毫秒/请求的速度频繁请求访问多个文件。

在我看来,签名计算本身并没有太多可以改进的地方,尽管上面提到的 rsa 库最近一次更新是将近 1.5 年前。我想知道是否有其他技术或设计可以优化此过程的性能以实现更高的可扩展性。还是我们只需要投入更多硬件并尝试以蛮力方式解决它?

一个优化可以使 API 端点接受每个请求的多个文件签名和 return 批量签名的 URL,而不是在单独的请求中单独处理它们,但是计算所有这些签名所需的总时间仍然存在。

使用签名 Cookie

当我使用带有许多私有 URL 的 CloudFront 时,我更喜欢使用 Signed Cookies when all the restrictions 满足。这不会加快签名 cookie 的生成速度,但它会将签名请求的数量减少为每个用户一个,直到它们过期。

调整 RSA 签名生成

我可以想象您可能有将签名 cookie 呈现为无效选项的要求。在那种情况下,我试图通过比较 RSA module used with boto and cryptography. Two additional alternative options are m2crypto and pycrypto 来加快签名,但对于这个例子,我将使用密码学。

为了测试使用不同模块签署 URL 的性能,我减少了要测试的方法 _sign_string to remove any logic except the signing of a string then created a new Distribution class. Then I took the private key and example URL from boto tests

结果表明加密速度更快,但每个签名请求仍需要接近 1 毫秒。这些结果因 iPython 在时序中使用范围变量而偏高。

timeit -n10000 rsa_distribution.create_signed_url(url, message, expire_time)
10000 loops, best of 3: 6.01 ms per loop

timeit -n10000 cryptography_distribution.create_signed_url(url, message, expire_time)
10000 loops, best of 3: 644 µs per loop

完整脚本:

from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes

import rsa

from boto.cloudfront.distribution import Distribution

from textwrap import dedent

# The private key provided in the Boto tests
pk_key = dedent("""
    -----BEGIN RSA PRIVATE KEY-----
    MIICXQIBAAKBgQDA7ki9gI/lRygIoOjV1yymgx6FYFlzJ+z1ATMaLo57nL57AavW
    hb68HYY8EA0GJU9xQdMVaHBogF3eiCWYXSUZCWM/+M5+ZcdQraRRScucmn6g4EvY
    2K4W2pxbqH8vmUikPxir41EeBPLjMOzKvbzzQy9e/zzIQVREKSp/7y1mywIDAQAB
    AoGABc7mp7XYHynuPZxChjWNJZIq+A73gm0ASDv6At7F8Vi9r0xUlQe/v0AQS3yc
    N8QlyR4XMbzMLYk3yjxFDXo4ZKQtOGzLGteCU2srANiLv26/imXA8FVidZftTAtL
    viWQZBVPTeYIA69ATUYPEq0a5u5wjGyUOij9OWyuy01mbPkCQQDluYoNpPOekQ0Z
    WrPgJ5rxc8f6zG37ZVoDBiexqtVShIF5W3xYuWhW5kYb0hliYfkq15cS7t9m95h3
    1QJf/xI/AkEA1v9l/WN1a1N3rOK4VGoCokx7kR2SyTMSbZgF9IWJNOugR/WZw7HT
    njipO3c9dy1Ms9pUKwUF46d7049ck8HwdQJARgrSKuLWXMyBH+/l1Dx/I4tXuAJI
    rlPyo+VmiOc7b5NzHptkSHEPfR9s1OK0VqjknclqCJ3Ig86OMEtEFBzjZQJBAKYz
    470hcPkaGk7tKYAgP48FvxRsnzeooptURW5E+M+PQ2W9iDPPOX9739+Xi02hGEWF
    B0IGbQoTRFdE4VVcPK0CQQCeS84lODlC0Y2BZv2JxW3Osv/WkUQ4dslfAQl1T303
    7uwwr7XTroMv8dIFQIPreoPhRKmd/SbJzbiKfS/4QDhU
    -----END RSA PRIVATE KEY-----""")

# Initializing keys in a global context
cryptography_private_key = serialization.load_pem_private_key(
    pk_key,
    password=None,
    backend=default_backend())


# Instantiate a signer object using PKCS 1v 15, this is not recommended but required for Amazon
def sign_with_cryptography(message):
    signer = cryptography_private_key.signer(
        padding.PKCS1v15(),
        hashes.SHA1())

    signer.update(message)
    return signer.finalize()


# Initializing the key in a global context
rsa_private_key = rsa.PrivateKey.load_pkcs1(pk_key)


def sign_with_rsa(message):
    signature = rsa.sign(str(message), rsa_private_key, 'SHA-1')

    return signature


# All this information comes from the Boto tests.
url = "http://d604721fxaaqy9.cloudfront.net/horizon.jpg?large=yes&license=yes"
expected_url = "http://d604721fxaaqy9.cloudfront.net/horizon.jpg?large=yes&license=yes&Expires=1258237200&Signature=Nql641NHEUkUaXQHZINK1FZ~SYeUSoBJMxjdgqrzIdzV2gyEXPDNv0pYdWJkflDKJ3xIu7lbwRpSkG98NBlgPi4ZJpRRnVX4kXAJK6tdNx6FucDB7OVqzcxkxHsGFd8VCG1BkC-Afh9~lOCMIYHIaiOB6~5jt9w2EOwi6sIIqrg_&Key-Pair-Id=PK123456789754"
message = "PK123456789754"
expire_time = 1258237200


class CryptographyDistribution(Distribution):
    def _sign_string(
            self,
            message,
            private_key_file=None,
            private_key_string=None):
        return sign_with_cryptography(message)


class RSADistribution(Distribution):
    def _sign_string(
            self,
            message,
            private_key_file=None,
            private_key_string=None):
        return sign_with_rsa(message)


cryptography_distribution = CryptographyDistribution()
rsa_distribution = RSADistribution()

cryptography_url = cryptography_distribution.create_signed_url(
    url,
    message,
    expire_time)

rsa_url = rsa_distribution.create_signed_url(
    url,
    message,
    expire_time)

assert cryptography_url == rsa_url == expected_url, "URLs do not match"

结论

虽然加密模块在这个测试中表现更好,但我建议尝试找到一种利用签名 cookie 的方法,但我希望这些信息有用。

简要

考虑您是否可以(除了使用 python-cryptography,根据 @erik-e)使用更短的密钥长度(并且可能 change keys more frequently),考虑到您的用例的细节。虽然我可以使用 AWS 在 ~1550µs 内生成的 2048 位密钥进行签名,但在 1028 位时只需要 ~307µs,在 768 位时只需~184µs,在 512 位时只需~113µs。

说明

稍微研究一下之后,我将转向另一个方向,并在 @erik-e 给出的(已经很棒的)答案的基础上进行构建。在我开始之前我应该​​提一下,我不知道这个想法有多可以接受;我只是报告它对性能的影响(请参阅 post 的结尾,了解我在安全 SE 上提出的问题,寻求对此的意见)。

我正在按照@erik-e 的建议收集使用 cryptography 签名的时间,并且由于它与我们现有的 S3 签名方法之间仍然存在很大的性能差距,我决定分析代码以查看如果看起来可能有任何明显的咀嚼时间:

>>> cProfile.runctx('[sign_url_cloudfront2("...") for x in range(0,100)]', globals(), locals(), sort="time")
         9403 function calls in 0.218 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
      200    0.161    0.001    0.161    0.001 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_sign}
      100    0.006    0.000    0.186    0.002 rsa.py:214(_finalize_pkey_ctx)
     1200    0.004    0.000    0.008    0.000 {isinstance}
      400    0.004    0.000    0.007    0.000 api.py:212(new)
      100    0.003    0.000    0.218    0.002 views.py:888(sign_url_cloudfront2)
      300    0.002    0.000    0.004    0.000 abc.py:128(__instancecheck__)
      100    0.002    0.000    0.008    0.000 hashes.py:53(finalize)
      200    0.002    0.000    0.005    0.000 gc_weakref.py:10(build)
      100    0.002    0.000    0.007    0.000 hashes.py:15(__init__)
      100    0.002    0.000    0.018    0.000 rsa.py:151(__init__)
      100    0.002    0.000    0.014    0.000 hashes.py:68(__init__)
      200    0.002    0.000    0.003    0.000 gc_weakref.py:14(remove)
      200    0.002    0.000    0.003    0.000 api.py:239(cast)
      100    0.002    0.000    0.190    0.002 rsa.py:207(finalize)
      200    0.001    0.000    0.007    0.000 api.py:325(gc)
      500    0.001    0.000    0.001    0.000 {getattr}
      400    0.001    0.000    0.001    0.000 {_cffi_backend.newp}
      400    0.001    0.000    0.001    0.000 api.py:150(_typeof)
      200    0.001    0.000    0.002    0.000 api.py:266(buffer)
      200    0.001    0.000    0.001    0.000 utils.py:18(<lambda>)
      300    0.001    0.000    0.001    0.000 _weakrefset.py:68(__contains__)
      200    0.001    0.000    0.001    0.000 {_cffi_backend.buffer}
      100    0.001    0.000    0.002    0.000 hashes.py:49(update)
      100    0.001    0.000    0.010    0.000 hashes.py:102(finalize)
      100    0.001    0.000    0.003    0.000 hashes.py:88(update)
      200    0.001    0.000    0.001    0.000 {method 'encode' of 'str' objects}
      100    0.001    0.000    0.019    0.000 rsa.py:528(signer)
      300    0.001    0.000    0.001    0.000 {len}
      100    0.001    0.000    0.001    0.000 base64.py:42(b64encode)
      100    0.001    0.000    0.008    0.000 backend.py:148(create_hash_ctx)
      200    0.001    0.000    0.001    0.000 {_cffi_backend.cast}
      200    0.001    0.000    0.001    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_get_digestbyname}
      100    0.001    0.000    0.001    0.000 {method 'format' of 'str' objects}
      100    0.001    0.000    0.003    0.000 rsa.py:204(update)
      200    0.000    0.000    0.000    0.000 {method 'pop' of 'dict' objects}
      100    0.000    0.000    0.000    0.000 {binascii.b2a_base64}
      200    0.000    0.000    0.000    0.000 {_cffi_backend.typeof}
      100    0.000    0.000    0.000    0.000 {time.time}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_DigestUpdate}
        1    0.000    0.000    0.218    0.218 <string>:1(<module>)
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_DigestInit_ex}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_CTX_new}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_CTX_free}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_DigestFinal_ex}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_MD_CTX_create}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_CTX_set_rsa_padding}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_size}
      100    0.000    0.000    0.000    0.000 {method 'translate' of 'str' objects}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_MD_CTX_cleanup}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_CTX_set_signature_md}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_sign_init}
      100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_MD_CTX_destroy}
        1    0.000    0.000    0.000    0.000 {range}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

虽然 signer 中可能隐藏着一些小的节省,但绝大部分时间都花在了 finalize() 调用中,而且几乎所有时间都花在了对打开SSL。虽然这有点令人失望,但它清楚地表明我应该查看实际的签名过程以节省开支。

我只是使用 CloudFront 为我们生成的 2048 位密钥,所以我决定看看较小的密钥会对性能产生什么影响。我使用较短的密钥重新运行 配置文件:

>>> cProfile.runctx('[sign_url_cloudfront2("...") for x in range(0,100)]', globals(), locals(), sort="time")
        9203 function calls in 0.063 seconds

  Ordered by: internal time

  ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     100    0.008    0.000    0.008    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_sign}
     400    0.005    0.000    0.008    0.000 api.py:212(new)
     100    0.004    0.000    0.033    0.000 rsa.py:214(_finalize_pkey_ctx)
    1200    0.004    0.000    0.008    0.000 {isinstance}
     100    0.003    0.000    0.063    0.001 views.py:897(sign_url_cloudfront2)
     300    0.002    0.000    0.004    0.000 abc.py:128(__instancecheck__)
     100    0.002    0.000    0.008    0.000 hashes.py:53(finalize)
     200    0.002    0.000    0.005    0.000 gc_weakref.py:10(build)
     100    0.002    0.000    0.007    0.000 hashes.py:15(__init__)
     100    0.002    0.000    0.014    0.000 hashes.py:68(__init__)
     100    0.002    0.000    0.018    0.000 rsa.py:151(__init__)
     200    0.002    0.000    0.003    0.000 gc_weakref.py:14(remove)
     100    0.001    0.000    0.036    0.000 rsa.py:207(finalize)
     200    0.001    0.000    0.003    0.000 api.py:239(cast)
     200    0.001    0.000    0.006    0.000 api.py:325(gc)
     500    0.001    0.000    0.001    0.000 {getattr}
     200    0.001    0.000    0.002    0.000 api.py:266(buffer)
     400    0.001    0.000    0.001    0.000 {_cffi_backend.newp}
     400    0.001    0.000    0.001    0.000 api.py:150(_typeof)
     100    0.001    0.000    0.010    0.000 hashes.py:102(finalize)
     200    0.001    0.000    0.002    0.000 utils.py:18(<lambda>)
     300    0.001    0.000    0.001    0.000 _weakrefset.py:68(__contains__)
     100    0.001    0.000    0.002    0.000 hashes.py:88(update)
     100    0.001    0.000    0.001    0.000 hashes.py:49(update)
     200    0.001    0.000    0.001    0.000 {method 'encode' of 'str' objects}
     200    0.001    0.000    0.001    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_get_digestbyname}
     100    0.001    0.000    0.001    0.000 base64.py:42(b64encode)
     100    0.001    0.000    0.008    0.000 backend.py:148(create_hash_ctx)
     100    0.001    0.000    0.019    0.000 rsa.py:520(signer)
     200    0.001    0.000    0.001    0.000 {_cffi_backend.buffer}
     200    0.001    0.000    0.001    0.000 {method 'pop' of 'dict' objects}
     200    0.001    0.000    0.001    0.000 {_cffi_backend.cast}
     100    0.001    0.000    0.001    0.000 {method 'format' of 'str' objects}
     100    0.001    0.000    0.001    0.000 {time.time}
     100    0.001    0.000    0.003    0.000 rsa.py:204(update)
     200    0.000    0.000    0.000    0.000 {len}
     200    0.000    0.000    0.000    0.000 {_cffi_backend.typeof}
     100    0.000    0.000    0.000    0.000 {binascii.b2a_base64}
     100    0.000    0.000    0.000    0.000 {method 'translate' of 'str' objects}
       1    0.000    0.000    0.063    0.063 <string>:1(<module>)
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_DigestUpdate}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_CTX_new}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_DigestInit_ex}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_MD_CTX_destroy}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_DigestFinal_ex}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_MD_CTX_create}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_sign_init}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_size}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_MD_CTX_cleanup}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_CTX_free}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_CTX_set_signature_md}
     100    0.000    0.000    0.000    0.000 {_Cryptography_cffi_a269d620xd5c405b7.EVP_PKEY_CTX_set_rsa_padding}
       1    0.000    0.000    0.000    0.000 {range}
       1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

正如我在对 erik-e 的回答的评论中提到的,我看到使用带有 cryptography 模块的 2048 位密钥的完整签名方法的运行时间约为 1550µs。使用 512 位密钥重复相同的测试可将运行时间降低至约 113 微秒(与我们的 S3 签名方法的约 30 微秒相差无几)。

这个结果看似有意义,但要看how acceptable it is to use a shorter key for your purpose. I was able to find a comment from March on a Mozilla issue report suggesting a 512-bit key could be broken for in 8 hours on EC2.