前言
最近一段,我花了一些时间在编译、优化我的一台运行OpenWRT的路由器,型号是 Linksys WRT 1900ACS 。为了扩展路由器的功能,就需要在OpenWRT上开发一些新的功能,尤其是需要使用到 AWS 上的一些有趣的服务。当我习惯性的开始使用AWS SDK 的时候才突然意识到,在一台硬件配置不高,软件极度精简的系统中使用这些 SDK 无疑是一件极为奢侈的想法。即使如我手上的这台硬件配置颇高的路由器,也不过只有128M 的存储、512M的内存资源而已。
这或许让我的工作更加有趣,让我可以更深入的去研究AWS 的API的调用机制以及如何更有效使用,而不是依赖于高度封装好的SDK。这个任务的主要挑战是成功的执行经过身份验证的AWS REST API请求,例如EC2 的安全组、VPC的ACL规则、Amazon S3上文件的存取以及其它一些有意思的功能。
为什么?
我的工作并非如“黑客”那样的非法使用系统。事实上AWS 早就针对REST API 的调用提供了标准的调用接口,其中最关键的环节就是名为Signatura Version 4 的API请求报头的签名过程。在AWS 的文档中对这个流程有详细的介绍,但我相信应该只有很少的人能够读完这个文档,原因是因为这个过程实在是繁-琐-无-比。理智的开发者通常会忽略这些API而更习惯于使用各类AWS 的SDK。甚至,某些简单的任务也完全可以通过aws-cli,使用一段脚本来解决问题。
但是,就像我的情况一样。某些场景下可能无法使用适用于工作平台或者编程语言的SDK,这些需求包括但不仅限于这些
- 资源限制。例如嵌入式环境中
- 性能要求。例如性能较低的CPU
这是我做的一个简单的性能对比。场景是针对S3 上的一个文件下载到本地
实现 |
代码行数 |
时间开销 |
awscli |
1 |
0.37s |
boto3 |
18 |
0.23s |
python |
133 |
0.14s |
- SDK的缺失。例如macOS 上的AWS SDK
- 缺少特定的语言的SDK。例如Rust等 (注:rusoto为非官方的SDK包)
- 现有SDK功能的缺失。例如 AWS Transcribe 实时转录的功能
- 减少依赖。例如使用Python 的boto3, 就需要安装这样的一些依赖项python3、python3-yaml、python3-pyasn1、python3-botocore、python3-rsa、 python3-colorama、python3-docutils、python3-s3transfer 等等
此外,了解并掌握了AWS REST API 的细节,对于开发人员在进行系统优化、架构设计以及提升系统安全性等方面一定大有裨益。
我们需要的工具
对于这项任务,我们将会用到:
- python3+ (python2 理论上也可以实现,但我没有去尝试)
- 可以安装Python 的requests 包(pip3 install requests)。也可以使用Python内置的urllib而不用requests。
- 文本编辑器 (例如我常用的vim)
- curl (用来请求 Web 服务的命令行工具)
- openssl (安全通信的基础软件包)
- sed (一种流编辑器,常用于Linux 脚本中)
我们将使用这些工具分别在python 程序以及shell 脚本中实现对于AWS API的调用。通常,AWS 的SDK (例如用于Python 的boto3)会帮助我们的应用自动完成请求的签名,因此对于开发者来说这个环节是透明的。而对于今天的这个任务我们将需要自己动手完成最重要的签名的操作。
相关的参考实现
类似于我的这个想法,早就有人实践过并分享出来。其中较为知名的有这样几个:
1、requests-aws4auth (https://github.com/sam-washington/requests-aws4auth)
Amazon Web Service身份验证版本4 的Python Request库的
2、aws-requests-auth(https://github.com/DavidMuller/aws-requests-auth)
AWS签名版本4签名过程的python requests module
3、aws-request-signer (https://github.com/iksteen/aws-request-signer)
使用AWS签名V4签署AWS请求的python库
上述3个开源的Python 库,除了最后一个在4个月前有过更新以外,其它的两个已经超过2年以上没有更新了,很难有信心去使用啊!最后介绍的一个比较有趣,因为这个方法没有使用boto3 却利用botocore 来实现签名,算是一种投机取巧的做法。
rl = 'https://s3.{}.amazonaws.com/'.format(os.environ['AWS_REGION'])
credentials = botocore.credentials.Credentials(
environ['AWS_ACCESS_KEY_ID'],
environ['AWS_SECRET_ACCESS_KEY'],
environ['AWS_SESSION_TOKEN'])
request = botocore.awsrequest.AWSRequest(method='GET', url=url)
auth.S3SigV4Auth(
credentials, 's3', os.environ['AWS_REGION']).add_auth(request)
response = botocore.httpsession.URLLib3Session().send(request.prepare())
为什么需要对API的请求签名?
几乎AWS 所有服务的每一个功能都提供了一个API,并且这些API都是REST API。 这就意味着我们可以通过HTTP 请求的方式完成对于AWS API 的调用。实现这样的调用是非常简单的事情,但是我们还需要在这个调用过程中满足这样的三个需求:
确保请求是由某个具有有效访问密钥的用户发送的
为了防止传输时请求被篡改,一些请求元素将用于计算请求的哈希(摘要),得到的哈希值将包括在请求中。在 AWS 服务收到请求时,它将使用相同信息计算哈希,并将其与请求中包括的哈希值进行匹配。如果值不匹配,AWS 将拒绝请求。
在大多数情况下,请求必须在请求中的时间戳的5分钟内到达 AWS。否则,AWS 将拒绝该请求。
这就引入了非常重要的一个方法-签名请求。当我们的应用将HTTP 请求发送到 AWS 时,需要对请求签名,以便 AWS 能够识别发送它们的用户。使用AWS 访问密钥来签名请求,该访问密钥包含访问密钥 ID 和秘密访问密钥。有一些请求不需要签名,如发送到Amazon S3的匿名请求以及AWS STS 中的一些 API 操作以外,其它的API 请求都需要签名。
Signature Version 4 的工作流程
要对请求签名,先要计算请求的哈希 (摘要)值。然后,使用这个哈希值、来自请求的其他一些信息以及AWS私密访问密钥,计算另一个称为“签名”的哈希值。
- 针对签名版本 4 创建规范请求
将请求的内容(主机、操作、标头等)组织为标准(规范)格式。规范请求是用于创建待签字符串的输入之一。请求规范具有以下格式
“ HTTP_Method” \ n“ Canonical_URI” \ n“ Canonical_Query” \ n“ Canonical_Headers” \ n“ Signed_Headers” \ n“ Request_payload”
项目 |
描述 |
HTTP_方法 |
描述要使用的HTTP方法。 (例如GET,PUT,POST等) |
Canonical_URI |
描述URI的绝对路径元素的URI编码,不包括查询字符部分 |
Canonical_Query |
询字符串中包含的每个参数部分。 URL编码的参数名称和值以“ =”连接,以字典顺序按参数名称排序,以“&”连接 |
Canonical_Headers |
标题名称和转换为小写的值均以“:”连接,并按参数名称按字典顺序排序,并在参数末尾添加换行符(\ n)并进行组合。 主机头是必需的 |
Signed_Headers |
按字典顺序对CanonicalHeaders中包含的参数的标题名称部分进行排序,并描述用“;”连接的字符串 |
Request_payload |
请求主体值是通过SHA256摘要生成的,并转换为十六进制字符串。 如果不存在,将插入一个空字符(“”),SHA256摘要将演变为16进制 |
- 创建签名版本 4 的待签字符串
使用规范请求和额外信息(例如算法、请求日期、凭证范围和规范请求的摘要(哈希))创建待签字符串。字符串具有以下格式:
“AWS4-HMAC-SHA256”\n “UTC 日期” \n“日期/区域ID / s3 / aws4_request” \ n“ Canonical_str”
项目 |
描述 |
AWS4密钥 |
它是将密钥与字符AWS4连接的字符串 |
日期 |
以YYYYMMDD格式指定请求日期。 在签名字符串中输入相同的日期。 |
区域代码 |
使用的AWS 区域代码,例如: cn-north-1 |
服务名称 |
服务名称字符串,例如:s3、ec2 |
Aws4_requestr |
固定字符串 |
- 为 AWS Signature 版本 4 计算签名
使用 AWS 秘密访问密钥作为初始哈希操作的密钥,对请求日期、区域和服务执行一系列加密哈希操作(HMAC 操作),从而派生签名密钥。在派生签名密钥后,通过对待签字符串执行加密哈希操作来计算签名。使用派生的签名密钥作为此操作的哈希密钥。格式如下:
MAC_SHA256(HMAC_SHA256(HMAC_SHA256(HMAC_SHA256(“AWS4”秘密密钥,日期),区域ID),“ s3”),“aws4_request”)
- 向 HTTP 请求添加签名
在计算签名后,将其添加到请求的 HTTP 标头或查询字符串中。具体说来,就是使用步骤3中的签名密钥,将步骤2中创建的签名字符串的SHA256 HMAC计算结果转换为十六进制字符。格式如下:
HMAC_SHA(签名密钥,签名字符串)
接下来,可以通过以下两种方式之一将签名添加到请求:
- 使用 HTTP Authorization 标头
- 将查询字符串值添加到请求中。由于签名是 URL 的一部分,因此这类 URL 被称为预签名URL
AWS 服务收到请求后,将执行您完成的相同步骤来计算请求中发送的签名。之后,AWS 会将计算得到的签名与您在请求中发送的签名进行比较。如果签名匹配,则处理请求。如果签名不匹配,则拒绝请求。
关于实现的细节,我们可以通过两个关键的函数一窥究竟(python 代码)
# Key derivation functions. See:
# http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-python
defsign(key, msg):
returnnew(key, msg.encode('utf-8'), hashlib.sha256).digest()
defgetSignatureKey(key, dateStamp, regionName, serviceName):
kDate = sign(('AWS4' + key).encode('utf-8'), dateStamp)
kRegion = sign(kDate, regionName)
kService = sign(kRegion, serviceName)
kSigning = sign(kService, 'aws4_request')
returnkSigning
特别注意事项:
- AWS支持两个签名的版本:Signature Version 4 和Signature Version 2
- 要求必须使用Signature Version 4。所有 AWS 服务(除Amazon SimpleDB)均支持Signature Version 4
- 所有 AWS 区域都支持Signature Version 4
- Signature Version 2即将失效!例如(Amazon S3的Signature Version 2将于2019年6月24日失效)
Signature V4 的实现
使用Python3、requests 以及HTTP 的get 方法,实现对于特定区域的运行中的EC2实例的查询
#!/usr/bin/env python3
# -*- coding:utf-8 -*-
importsys
importos
importdatetime
importhashlib
importhmac
importrequests
fromexceptions importHTTPError
fromexceptions importTimeout
ALGORITHM = 'AWS4-HMAC-SHA256'
METHOD = 'GET'
def_sign(key, msg):
returnnew(key, msg.encode('utf-8'), hashlib.sha256).digest()
defget_SignatureKey(key, dateStamp, regionName, serviceName):
date = _sign(('AWS4'+ key).encode('utf-8'), dateStamp)
region = _sign(date, regionName)
service = _sign(region, serviceName)
signing = _sign(service, 'aws4_request')
returnsigning
defget_key():
returnenviron.get('AWS_ACCESS_KEY_ID'), \
environ.get('AWS_SECRET_ACCESS_KEY')
defget_datetime():
current = datetime.datetime.utcnow()
returnstrftime('%Y%m%dT%H%M%SZ'), current.strftime('%Y%m%d')
defget_endpoint(service, region):
return'https://{}.{}.amazonaws.com'.format(service, region)
defget_host(endpoint):
returnreplace('https://', '')
defget_reqUrl(endpoint, canonical_querystring):
return'{}?{}'.format(endpoint, canonical_querystring)
defget_header(region, service, request_parameters):
amzdate, datestamp = get_datetime()
endpoint = get_endpoint(service, region)
host = get_host(endpoint)
access_key, secret_key = get_key()
ifaccess_key is None or secret_key is None:
print('No access key is available.')
exit()
canonical_uri = '/'
canonical_querystring = request_parameters
canonical_headers = 'host:{}\nx-amz-date:{}\n'.format(host, amzdate)
signed_headers = 'host;x-amz-date'
payload_hash = hashlib.sha256(('').encode('utf-8')).hexdigest()
canonical_request = '{}\n{}\n{}\n{}\n{}\n{}'.format(
METHOD,
canonical_uri,
canonical_querystring,
canonical_headers,
signed_headers,
payload_hash
)
credential_scope = '{}/{}/{}/aws4_request'.format(
datestamp, region, service)
string_to_sign = '{}\n{}\n{}\n{}'.format(
ALGORITHM,
amzdate,
credential_scope,
sha256(
encode('utf-8')).hexdigest()
)
signing_key = get_SignatureKey(secret_key, datestamp, region, service)
signature = hmac.new(
signing_key,
(string_to_sign).encode('utf-8'),
sha256
).hexdigest()
authorization_header = \
'{} Credential={}/{},SignedHeaders={},Signature={}'.format(
ALGORITHM,
access_key,
credential_scope,
signed_headers,
signature
)
headers = {'x-amz-date': amzdate, 'Authorization': authorization_header}
request_url = get_reqUrl(endpoint, canonical_querystring)
return request_url, headers
defmain():
service = 'ec2'
region = 'us-west-1'
action = 'DescribeInstances'\
'&Filter.1.Name=instance-state-name&Filter.1.Value.1=running'
version = "2016-11-15"
request_parameters = 'Action={}&Version={}'.format(action, version)
request_url, headers = get_header(region, service, request_parameters)
print('\nBEGIN REQUEST++++++++++++++++++++++++++++++++++++')
print('Request URL = {}'.format(request_url))
print('Request header = {}'.format(str(headers)))
try:
res = requests.get(request_url, headers=headers, timeout=(2, 5))
except Timeout:
print('The request timed out')
except HTTPError as http_err:
print(f'HTTP error occurred: {http_err}')
except Exception as err:
print(f'Other error occurred: {err}')
else:
print('\nRESPONSE++++++++++++++++++++++++++++++++++++')
print('Response code: %d\n' % res.status_code)
print(res.text)
if__name__ == "__main__":
main()
使用Python3、rrequests 实现的对于Amazon Translate 的调用,实现英文-中文的翻译
#!/usr/bin/env python3
# -*- coding:utf-8 -*-
# Writen by Lianghong2020-03-12 11:42:56
importsys
importos
importdatetime
importhashlib
importhmac
importjson
importrequests
fromexceptions importHTTPError
fromexceptions importTimeout
ALGORITHM = 'AWS4-HMAC-SHA256'
METHOD = 'POST'
def_sign(key, msg):
returnnew(key, msg.encode("utf-8"), hashlib.sha256).digest()
defget_SignatureKey(key, datestamp, regionName, serviceName):
k_date = _sign(('AWS4'+ key).encode('utf-8'), datestamp)
k_region = _sign(k_date, regionName)
k_service = _sign(k_region, serviceName)
k_signing = _sign(k_service, 'aws4_request')
returnk_signing
defget_key():
returnenviron.get('AWS_ACCESS_KEY_ID'), \
environ.get('AWS_SECRET_ACCESS_KEY')
defget_datetime():
current = datetime.datetime.utcnow()
returnstrftime('%Y%m%dT%H%M%SZ'), current.strftime('%Y%m%d')
defget_host(service, region):
return'{}.{}.amazonaws.com'.format(service, region)
defget_header(service, region, request_parameters):
access_key, secret_key = get_key()
ifaccess_key is None or secret_key is None:
print('No access key is available.')
exit()
amz_date, date_stamp = get_datetime()
host = get_host(service, region)
canonical_uri = '/'
canonical_querystring = ''
content_type = 'application/x-amz-json-1.1'
amz_target = 'AWSShineFrontendService_20170701.TranslateText'
canonical_headers = \
'content-type:{}\nhost:{}\nx-amz-date:{}\nx-amz-target:{}\n'.format(
content_type,
host,
amz_date,
amz_target
)
signed_headers = 'content-type;host;x-amz-date;x-amz-target'
payload_hash = hashlib.sha256(
encode(
'utf-8'
)).hexdigest()
canonical_request = '{}\n{}\n{}\n{}\n{}\n{}'.format(
METHOD,
canonical_uri,
canonical_querystring,
canonical_headers,
signed_headers,
payload_hash
)
credential_scope = '{}/{}/{}/aws4_request'.format(
date_stamp, region, service)
string_to_sign = '{}\n{}\n{}\n{}'.format(
ALGORITHM, amz_date, credential_scope,
sha256(canonical_request.encode('utf-8')).hexdigest()
)
signing_key = get_SignatureKey(secret_key, date_stamp, region, service)
signature = hmac.new(
signing_key,
(string_to_sign).encode('utf-8'),
sha256).hexdigest()
authorization_header = \
'{} Credential={}/{},SignedHeaders={},Signature={}'.format(
ALGORITHM,
access_key,
credential_scope,
signed_headers,
signature
)
headers = {'Content-Type': content_type,
'X-Amz-Date': amz_date,
'X-Amz-Target': amz_target,
'Authorization': authorization_header}
return headers
defmain():
service = 'translate'
region = 'ap-northeast-1'
host = get_host(service, region)
endpoint = 'https://{}/'.format(host)
text = 'Amazon Translate is a text translation service that use '\
'advanced machine learning technologies to provide high-quality '\
'translation on demand. You can use Amazon Translate to translate '\
'unstructured text documents or to build applications that work in '\
'multiple languages.'\
'Amazon Translate provides translation between a source language '\
'(the input language) and a target language (the output language). ' \
'A source language-target language combination is known as a '\
'language pair.'
source_lang_code = 'en'
target_lang_code = 'zh'
request_parameters = '{{"{}": "{}","{}": "{}","{}": "{}"}}'.format(
"Text",
text,
"SourceLanguageCode",
source_lang_code,
"TargetLanguageCode",
target_lang_code
)
headers = get_header(service, region, request_parameters)
# print('endpoint is ==>\n{}\n'.format(endpoint))
# print('request_parameters is ==>\n{}\n'.format(request_parameters))
# print('headers is ==>\n{}\n'.format(headers))
try:
res = requests.post(
endpoint,
data=request_parameters,
headers=headers
)
except Timeout:
print('The request timed out')
except HTTPError as http_err:
print(f'HTTP error occurred: {http_err}')
except Exception as err:
print(f'Other error occurred: {err}')
else:
json_content = json.loads(res.text)
print('The original is -->\n{}\n'.format(text))
print('The translation is -->\n{}\n'.format(
json_content['TranslatedText']
))
# print('Response:\n\t{}'.format(res.text))
if__name__ == "__main__":
main()
如果不喜欢Python也没有关系。即使shell的脚本仅仅使用curl、openssl以及sed,就可以实现上传文件到Amazon S3的存储桶之中的操作
#!/bin/bash
set -e
fileLocal=""
bucket=""
region=""
storageClass="${4:-STANDARD}"# or 'REDUCED_REDUNDANCY'
AWS_CONFIG_FILE="$HOME/.aws/credentials"
awsStringSign4() {
kSecret="AWS4$1"
kDate=$(printf '%s'"$2" | openssl dgst -sha256 -hex -mac HMAC -macopt "key:${kSecret}" 2>/dev/null | sed 's/^.* //')
kRegion=$(printf '%s'"$3" | openssl dgst -sha256 -hex -mac HMAC -macopt "hexkey:${kDate}" 2>/dev/null | sed 's/^.* //')
kService=$(printf '%s'"$4" | openssl dgst -sha256 -hex -mac HMAC -macopt "hexkey:${kRegion}" 2>/dev/null | sed 's/^.* //')
kSigning=$(printf 'aws4_request'| openssl dgst -sha256 -hex -mac HMAC -macopt "hexkey:${kService}" 2>/dev/null | sed 's/^.* //')
signedString=$(printf '%s'"$5" | openssl dgst -sha256 -hex -mac HMAC -macopt "hexkey:${kSigning}" 2>/dev/null | sed 's/^.* //')
printf '%s'"${signedString}"
}
iniGet() {
printf '%s'$(sed -n "/^[ \t]*\[$2\]/,/\[/s/^[ \t]*$3[ \t]*=[ \t]*//p" $1)
}
# Initialize access keys
if[ -z "${AWS_CONFIG_FILE:-}"]; then
if[ -z "${AWS_ACCESS_KEY:-}" ]; then
echo 'AWS_CONFIG_FILE or AWS_ACCESS_KEY/AWS_SECRET_KEY envvars not set.'
exit 1
else
awsAccess="${AWS_ACCESS_KEY}"
awsSecret="${AWS_SECRET_KEY}"
awsRegion=${region}
fi
else
awsProfile='default'
awsAccess="$(iniGet "${AWS_CONFIG_FILE}" "${awsProfile}" 'aws_access_key_id')"
awsSecret="$(iniGet "${AWS_CONFIG_FILE}" "${awsProfile}" 'aws_secret_access_key')"
awsRegion="$(iniGet "${AWS_CONFIG_FILE}" "${awsProfile}" 'region')"
fi
# Initialize defaults
fileRemote="${fileLocal}"
if[ -z "${region}"]; then
region="${awsRegion}"
fi
echo "Uploading""${fileLocal}""->" "${bucket}" "${region}" "${storageClass}"
# Initialize helper variables
httpReq='PUT'
authType='AWS4-HMAC-SHA256'
service='s3'
baseUrl=".${service}.amazonaws.com"
dateValueS=$(date -u +'%Y%m%d')
dateValueL=$(date -u +'%Y%m%dT%H%M%SZ')
ifhash file 2>/dev/null; then
contentType="$(file -b --mime-type "${fileLocal}")"
else
contentType='application/octet-stream'
fi
# 0. Hash the file to be uploaded
if[ -f "${fileLocal}"]; then
payloadHash=$(openssl dgst -sha256 -hex < "${fileLocal}"2>/dev/null | sed 's/^.* //')
else
echo "File not found: '${fileLocal}'"
exit 1
fi
# 1. Create canonical request
# NOTE: order significant in ${headerList} and ${canonicalRequest}
headerList='content-type;host;x-amz-content-sha256;x-amz-date;x-amz-server-side-encryption;x-amz-storage-class'
canonicalRequest="\
${httpReq}
/${fileRemote}
content-type:${contentType}
host:${bucket}${baseUrl}
x-amz-content-sha256:${payloadHash}
x-amz-date:${dateValueL}
x-amz-server-side-encryption:AES256
x-amz-storage-class:${storageClass}
${headerList}
${payloadHash}"
# Hash it
canonicalRequestHash=$(printf '%s'"${canonicalRequest}"| openssl dgst -sha256 -hex 2>/dev/null | sed 's/^.* //')
# 2. Create string to sign
stringToSign="\
${authType}
${dateValueL}
${dateValueS}/${region}/${service}/aws4_request
${canonicalRequestHash}"
# 3. Sign the string
signature=$(awsStringSign4 "${awsSecret}""${dateValueS}" "${region}" "${service}" "${stringToSign}")
# Upload
curl -s -L --proto-redir =https -X "${httpReq}"-T "${fileLocal}" \
-H "Content-Type: ${contentType}" \
-H "Host: ${bucket}${baseUrl}" \
-H "X-Amz-Content-SHA256: ${payloadHash}" \
-H "X-Amz-Date: ${dateValueL}" \
-H "X-Amz-Server-Side-Encryption: AES256" \
-H "X-Amz-Storage-Class: ${storageClass}" \
-H "Authorization: ${authType} Credential=${awsAccess}/${dateValueS}/${region}/${service}/aws4_request, SignedHeaders=${headerList}, Signature=${signature}" \
"https://${bucket}${baseUrl}/${fileRemote}"
纸上得来终觉浅,据知此事要躬行。最初开始阅读Signature Version 4 的文档倍觉繁琐,几乎不能坚持下去。屡经挫折,尤其是那个脚本实现的S3上传的例子足足折磨了我一天的时间。但是当成功的完成几个例子之后就顿时觉得融会贯通,欲罢不能了。这个小小的实践,让我对于AWS API的设计与实现有了更进一层的了解。
本篇作者