CloudKit 服务器到服务器身份验证
Posted
技术标签:
【中文标题】CloudKit 服务器到服务器身份验证【英文标题】:CloudKit Server-to-Server authentication 【发布时间】:2016-05-16 19:18:51 【问题描述】:Apple 发布了一种针对 CloudKit 的服务器到服务器进行身份验证的新方法。 https://developer.apple.com/library/content/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW6
我尝试针对 CloudKit 和此方法进行身份验证。起初我生成了密钥对并将公钥提供给 CloudKit,到目前为止没有问题。
我开始构建请求标头。根据文档,它应该如下所示:
X-Apple-CloudKit-Request-KeyID: [keyID]
X-Apple-CloudKit-Request-ISO8601Date: [date]
X-Apple-CloudKit-Request-SignatureV1: [signature]
[keyID],没问题。您可以在 CloudKit 仪表板中找到它。
[日期],我认为这应该有效:2016-02-06T20:41:00Z
[签名],问题出在这里...
文档说:
在步骤 1 中创建的签名。
第 1 步说:
连接以下参数并用冒号分隔。
[Current date]:[Request body]:[Web Service URL]
我问自己“为什么我必须生成密钥对?”。 但是第 2 步说:
使用您的私钥计算此消息的 ECDSA 签名。
也许他们的意思是用私钥签署串联签名并将其放入标题中?反正我都试过了……
我的这个(无符号)签名值示例如下:
2016-02-06T20:41:00Z:YTdkNzAwYTllNjI1M2EyZTllNDNiZjVmYjg0MWFhMGRiMTE2MjI1NTYwNTA2YzQyODc4MjUwNTQ0YTE5YTg4Yw==:https://api.apple-cloudkit.com/database/1/[iCloud Container]/development/public/records/lookup
请求正文值经过 SHA256 散列,然后经过 base64 编码。我的问题是,我应该用“:”连接,但网址和日期也包含“:”。这是正确的吗? (我还尝试对 URL 进行 URL 编码并删除日期中的“:”)。 接下来,我用 ECDSA 签署了这个签名字符串,将其放入标头并发送。但我总是得到 401“身份验证失败”。为了签名,我使用了ecdsa python 模块,带有以下命令:
from ecdsa import SigningKey
a = SigningKey.from_pem(open("path_to_pem_file").read())
b = "[date]:[base64(request_body)]:/database/1/iCloud....."
print a.sign(b).encode('hex')
也许 python 模块不能正常工作。但它可以从私钥生成正确的公钥。所以我希望其他功能也能工作。
有没有人设法使用服务器到服务器的方法对 CloudKit 进行身份验证?它是如何正常工作的?
编辑:正确的 Python 版本
from ecdsa import SigningKey
import ecdsa, base64, hashlib
a = SigningKey.from_pem(open("path_to_pem_file").read())
b = "[date]:[base64(sha256(request_body))]:/database/1/iCloud....."
signature = a.sign(b, hashfunc=hashlib.sha256, sigencode=ecdsa.util.sigencode_der)
signature = base64.b64encode(signature)
print signature #include this into the header
【问题讨论】:
苹果文档完全令人困惑,我基本上和你一样被困在同一个地方。我正在使用 Ruby,但结果是一样的。 这里也一样,我使用的是 bash。除非我发送的日期不正确,否则我总是会收到 401,然后我会收到 500。 我正在做与编辑中描述的完全相同的事情。但我仍然继续获得 401。正确的做法是base64(sha256(request_body))
是 base64.b64encode(hashlib.sha256(request_body).hexdigest())
,对吗?另外,为什么 ISO 日期时间字符串末尾有一个Z
?
@BigB 你用 ruby 解决了这个问题吗?
@Crashalot 不,我从来没有让它工作,我最终只是运行我自己的 MongoDB 服务器。
【参考方案1】:
消息的最后部分
[Current date]:[Request body]:[Web Service URL]
不得包含域(它必须包含任何查询参数):
2016-02-06T20:41:00Z:YTdkNzAwYTllNjI1M2EyZTllNDNiZjVmYjg0MWFhMGRiMTE2MjI1NTYwNTA2YzQyODc4MjUwNTQ0YTE5YTg4Yw==:/database/1/[iCloud Container]/development/public/records/lookup
使用换行符以提高可读性:
2016-02-06T20:41:00Z
:YTdkNzAwYTllNjI1M2EyZTllNDNiZjVmYjg0MWFhMGRiMTE2MjI1NTYwNTA2YzQyODc4MjUwNTQ0YTE5YTg4Yw==
:/database/1/[iCloud Container]/development/public/records/lookup
下面展示了如何用伪代码计算头部值
确切的 API 调用取决于您使用的具体语言和加密库。
//1. Date
//Example: 2016-02-07T18:58:24Z
//Pitfall: make sure to not include milliseconds
date = isoDateWithoutMilliseconds()
//2. Payload
//Example (empty string base64 encoded; GET requests):
//47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
//Pitfall: make sure the output is base64 encoded (not hex)
payload = base64encode(sha256(body))
//3. Path
//Example: /database/1/[containerIdentifier]/development/public/records/lookup
//Pitfall: Don't include the domain; do include any query parameter
path = stripDomainKeepQueryParams(url)
//4. Message
//Join date, payload, and path with colons
message = date + ':' + payload + ':' + path
//5. Compute a signature for the message using your private key.
//This step looks very different for every language/crypto lib.
//Pitfall: make sure the output is base64 encoded.
//Hint: the key itself contains information about the signature algorithm
// (on NodeJS you can use the signature name 'RSA-SHA256' to compute a
// the correct ECDSA signature with an ECDSA key).
signature = base64encode(sign(message, key))
//6. Set headers
X-Apple-CloudKit-Request-KeyID = keyID
X-Apple-CloudKit-Request-ISO8601Date = date
X-Apple-CloudKit-Request-SignatureV1 = signature
//7. For POST requests, don't forget to actually send the unsigned request body
// (not just the headers)
【讨论】:
我选择这个作为答案。感谢您的帮助和伪代码。其他答案也很有帮助。【参考方案2】:提取 Apple 的 cloudkit.js 实现并使用 Apple 示例代码 node-client-s2s/index.js 中的第一个调用,您可以构造以下内容:
您使用 sha256
散列请求正文请求:
var crypto = require('crypto');
var bodyHasher = crypto.createHash('sha256');
bodyHasher.update(requestBody);
var hashedBody = bodyHasher.digest("base64");
使用配置中提供的私钥对[Current date]:[Request body]:[Web Service URL]
有效负载进行签名。
var c = crypto.createSign("RSA-SHA256");
c.update(rawPayload);
var requestSignature = c.sign(key, "base64");
另一个注意事项是[Web Service URL]
有效负载组件不得包含域,但它确实需要任何查询参数。
确保X-Apple-CloudKit-Request-ISO8601Date
中的日期值与签名中的相同。 (这些细节没有完整记录,但通过查看 CloudKit.js 实现可以观察到)。
一个更完整的 nodejs 示例如下所示:
(function()
const https = require('https');
var fs = require('fs');
var crypto = require('crypto');
var key = fs.readFileSync(__dirname + '/eckey.pem', "utf8");
var authKeyID = 'auth-key-id';
// path of our request (domain not included)
var requestPath = "/database/1/iCloud.containerIdentifier/development/public/users/current";
// request body (GET request is blank)
var requestBody = '';
// date string without milliseconds
var requestDate = (new Date).toISOString().replace(/(\.\d\d\d)Z/, "Z");
var bodyHasher = crypto.createHash('sha256');
bodyHasher.update(requestBody);
var hashedBody = bodyHasher.digest("base64");
var rawPayload = requestDate + ":" + hashedBody + ":" + requestPath;
// sign payload
var c = crypto.createSign("sha256");
c.update(rawPayload);
var requestSignature = c.sign(key, "base64");
// put headers together
var headers =
'X-Apple-CloudKit-Request-KeyID': authKeyID,
'X-Apple-CloudKit-Request-ISO8601Date': requestDate,
'X-Apple-CloudKit-Request-SignatureV1': requestSignature
;
var options =
hostname: 'api.apple-cloudkit.com',
port: 443,
path: requestPath,
method: 'GET',
headers: headers
;
var req = https.request(options, (res) =>
//... handle nodejs response
);
req.end();
)();
这也作为一个要点存在:https://gist.github.com/jessedc/a3161186b450317a9cb5
在命令行中使用 openssl(更新)
第一次散列可以用这个命令完成:
openssl sha -sha256 -binary < body.txt | base64
要签署请求的第二部分,您需要比 OSX 10.11 附带的更现代的 openSSL 版本并使用以下命令:
/usr/local/bin/openssl dgst -sha256WithRSAEncryption -binary -sign ck-server-key.pem raw_signature.txt | base64
感谢@maurice_vB below and on twitter for this info
【讨论】:
您是否遇到“无法加载密钥”或类似情况?那是因为您执行了错误的编码,将-sha256
更改为 -ecdsa-with-SHA1
就可以了。即使它说 SHA1,密钥也会告诉 openssl 使用 ecdsa。
当你实际对payload进行签名时,使用"RSA-SHA256"
而不是"sha256"
,例如:var c = crypto.createSign("RSA-SHA256");
原因是密钥确实包含有关算法(ECDSA vs RSA)的信息,但不包含有关编码(SHA1 与 SHA256)。
@MaxGunther 是的,你是对的。 Apple 实施使用“RSA-SHA256”。不过,使用“sha256”似乎确实有效。【参考方案3】:
我在 php 中做了一个工作代码示例:https://gist.github.com/Mauricevb/87c144cec514c5ce73bd (基于@Jessedc 的 javascript 示例)
顺便说一句,请确保您将日期时间设置为 UTC 时区。因此,我的代码不起作用。
【讨论】:
太奇怪了,我可以运行 Apple 的示例脚本,但是这个 PHP 脚本无法加载我的密钥。如果我将其更改为使用 ecdsa-with-sha1 而不是 rsa256,我会得到臭名昭著的AUTHENTICATION_FAILED
。
确保在您的计算机上更新 openssl。然后它将毫无问题地工作。我在我的 Mac 上遇到了同样的问题。您也可以在服务器而不是本地 PC 上尝试脚本。特定于 OSX:Mac 带有过时的 openssl 版本,因此您始终需要更新 openssl 才能使其正常工作。
如果我使用最新的 openssl 版本(自制软件),它确实不适用于 ecdsa-with-sha1。你的 php 脚本至少可以工作。我的python代码也不起作用。我仍在调查这个问题。感谢您的脚本;)
我不确定这里是否是一个好方法,但奇怪的是我发现 GET 请求确实有效,但不是 POST 请求。可能与主体的构造方式有关,但我已经在苹果节点示例中打印了请求的字面意思部分,虽然它在 javascript 中工作,但我无法让它在其他地方工作。
你试过我的PHP代码了吗?该代码发送有效的 POST 请求。【参考方案4】:
从我在 Node.js 中工作的一个项目中提炼出来的。也许你会发现它很有用。替换X-Apple-CloudKit-Request-KeyID
和requestOptions.path
中的容器标识符以使其工作。
私钥/ pem 使用:openssl ecparam -name prime256v1 -genkey -noout -out eckey.pem
生成,并生成公钥以在 CloudKit 仪表板openssl ec -in eckey.pem -pubout
上注册。
var crypto = require("crypto"),
https = require("https"),
fs = require("fs")
var CloudKitRequest = function(payload)
this.payload = payload
this.requestOptions = // Used with `https.request`
hostname: "api.apple-cloudkit.com",
port: 443,
path: '/database/1/iCloud.com.your.container/development/public/records/modify',
method: 'POST',
headers: // We will add more headers in the sign methods
"X-Apple-CloudKit-Request-KeyID": "your-ck-request-keyID"
签署请求:
CloudKitRequest.prototype.sign = function(privateKey)
var dateString = new Date().toISOString().replace(/\.[0-9]+?Z/, "Z"), // NOTE: No milliseconds
hash = crypto.createHash("sha256"),
sign = crypto.createSign("RSA-SHA256")
// Create the hash of the payload
hash.update(this.payload, "utf8")
var payloadSignature = hash.digest("base64")
// Create the signature string to sign
var signatureData = [
dateString,
payloadSignature,
this.requestOptions.path
].join(":") // [Date]:[Request body]:[Web Service URL]
// Construct the signature
sign.update(signatureData)
var signature = sign.sign(privateKey, "base64")
// Update the request headers
this.requestOptions.headers["X-Apple-CloudKit-Request-ISO8601Date"] = dateString
this.requestOptions.headers["X-Apple-CloudKit-Request-SignatureV1"] = signature
return signature // This might be useful to keep around
现在你可以发送请求了:
CloudKitRequest.prototype.send = function(cb)
var request = https.request(this.requestOptions, function(response)
var responseBody = ""
response.on("data", function(chunk)
responseBody += chunk.toString("utf8")
)
response.on("end", function()
cb(null, JSON.parse(responseBody))
)
)
request.on("error", function(err)
cb(err, null)
)
request.end(this.payload)
所以给出以下内容:
var privateKey = fs.readFileSync("./eckey.pem"),
creationPayload = JSON.stringify(
"operations": [
"operationType" : "create",
"record" :
"recordType" : "Post",
"fields" :
"title" : "value" : "A Post From The Server"
]
)
使用请求:
var creationRequest = new CloudKitRequest(creationPayload)
creationRequest.sign(privateKey)
creationRequest.send(function(err, response)
console.log("Created a new entry with error", err, "and respone", response)
)
为了您的复制粘贴乐趣:https://gist.github.com/spllr/4bf3fadb7f6168f67698(已编辑)
【讨论】:
为什么当使用像“Théâtre des Variétés”这样的值时,它会因身份验证错误而失败? @DDD 发现了问题。哈希需要显式设置输入编码为 utf8。所以使用hash.update(this.payload, "utf8")
可以解决这个问题。将更新示例。谢谢你的收获。【参考方案5】:
如果其他人试图通过 Ruby 执行此操作,则需要一个关键方法别名来猴子修补 OpenSSL 库以使其工作:
def signature_for_request(body_json, url, iso8601_date)
body_sha_hash = Digest::SHA256.digest(body_json)
payload_for_signature = [iso8601_date, Base64.strict_encode64(body_sha_hash), url].join(":")
OpenSSL::PKey::EC.send(:alias_method, :private?, :private_key?)
ec = OpenSSL::PKey::EC.new(CK_PEM_STRING)
digest = OpenSSL::Digest::SHA256.new
signature = ec.sign(digest, payload_for_signature)
base64_signature = Base64.strict_encode64(signature)
return base64_signature
end
请注意,在上面的示例中,url 是不包括域组件的路径(以 /database... 开头),CK_PEM_STRING 只是在设置您的私钥/公钥对时生成的 pem 的 File.read。
iso8601_date 最容易使用以下方法生成:
Time.now.utc.iso8601
当然,您希望将其存储在一个变量中以包含在您的最终请求中。最终请求的构建可以使用以下模式:
def perform_request(url, body, iso8601_date)
signature = self.signature_for_request(body, url, iso8601_date)
uri = URI.parse(CK_SERVICE_BASE + url)
header =
"Content-Type" => "text/plain",
"X-Apple-CloudKit-Request-KeyID" => CK_KEY_ID,
"X-Apple-CloudKit-Request-ISO8601Date" => iso8601_date,
"X-Apple-CloudKit-Request-SignatureV1" => signature
# Create the HTTP objects
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri.request_uri, header)
request.body = body
# Send the request
response = http.request(request)
return response
end
现在对我来说就像一个魅力。
【讨论】:
谢谢。唯一的问题是它在signature = ec.sign(digest, payload_for_signature)
行上给出了“错误的公钥类型(OpenSSL::PKey::PKeyError)”的错误。有什么想法吗?
我的怀疑是您可能生成了错误类型的密钥,或者 CK_PEM_STRING 可能没有从文件中正确读取,或者如果您从终端复制并粘贴它,则以某种方式更改了它?我建议直接从文件中读取它。【参考方案6】:
我遇到了同样的问题,最终编写了一个与 python-requests 配合使用的库,以与 Python 中的 CloudKit API 交互。
pip install requests-cloudkit
安装后,只需导入身份验证处理程序 (CloudKitAuth
) 并直接将其用于请求。它将透明地验证您向 CloudKit API 发出的任何请求。
>>> import requests
>>> from requests_cloudkit import CloudKitAuth
>>> auth = CloudKitAuth(key_id=YOUR_KEY_ID, key_file_name=YOUR_PRIVATE_KEY_PATH)
>>> requests.get("https://api.apple-cloudkit.com/database/[version]/[container]/[environment]/public/zones/list", auth=auth)
如果您想贡献或报告问题,可以通过https://github.com/lionheart/requests-cloudkit 获取 GitHub 项目。
【讨论】:
以上是关于CloudKit 服务器到服务器身份验证的主要内容,如果未能解决你的问题,请参考以下文章
无法让 CloudKit 进行身份验证(使用 Javascript 和服务器到服务器密钥)
Cloudkit 身份验证仅适用于我的 alt 帐户,不适用于 Dev 或 Tester 帐户
将 CloudKit Web Services 的身份验证流程与 Zapier 结合使用