I'm building a PHP based webpage. I want to use REST APIs to store, read and update data in an AZURE Cosmos DB.
First challenge is to generate the authentication token with the masterkey. I used the Microsoft documentation: https://learn.microsoft.com/de-de/rest/api/cosmos-db/access-control-on-cosmosdb-resources
and a postman collection* as a reference to build the code below.
*see
https://www.youtube.com/watch?v=2ndj_-zp82Y
https://github.com/MicrosoftCSA/documentdb-postman-collection
I used the console log from postman to compare each step and figured out that there are different results** but I have no clue how to get the same results from my PHP like from Postman
**
key64 PHP:
WUtDMFF5VWZkZ1NsTFp5UmU5ZVBMbW9jV3ZSN.....==
key64 Postman:
60a0b443251f7604a52d9c917bd78f2e6a1c5af4790d3f67dc7dbd513d173418... NO == at the end
authstring PHP:
type%253Dmaster%2526ver%253D1.0%2526sig%.....
authstring POSTMAN:
type%3Dmaster%26ver%3D1.0%26sig%3.....
POSTMAN (JS)
// store our master key for documentdb
var mastKey = postman.getEnvironmentVariable("DocumentDBMasterKey");
console.log("mastKey = " + mastKey);
// store our date as RFC1123 format for the request
var today = new Date();
var UTCstring = today.toUTCString();
postman.setEnvironmentVariable("RFC1123time", UTCstring);
// Grab the request url
var url = request.url.trim();
console.log("request url = " + url);
// strip the url of the hostname up and leading slash
var strippedurl = url.replace(new RegExp('^https?://[^/]+/'),'/');
console.log ("stripped Url = " + strippedurl);
// push the parts down into an array so we can determine if the call is on a specific item
// or if it is on a resource (odd would mean a resource, even would mean an item)
var strippedparts = strippedurl.split("/");
var truestrippedcount = (strippedparts.length - 1);
console.log(truestrippedcount);
// define resourceId/Type now so we can assign based on the amount of levels
var resourceId = "";
var resType = "";
// its odd (resource request)
if (truestrippedcount % 2)
{
console.log("odd");
// assign resource type to the last part we found.
resType = strippedparts[truestrippedcount];
console.log("resType");
console.log(resType);
if (truestrippedcount > 1)
{
// now pull out the resource id by searching for the last slash and substringing to it.
var lastPart = strippedurl.lastIndexOf("/");
resourceId = strippedurl.substring(1,lastPart);
console.log(resourceId);
}
}
else // its even (item request on resource)
{
console.log("even");
// assign resource type to the part before the last we found (last is resource id)
resType = strippedparts[truestrippedcount - 1];
console.log("resType");
// finally remove the leading slash which we used to find the resource if it was
// only one level deep.
strippedurl = strippedurl.substring(1);
console.log("strippedurl");
// assign our resourceId
resourceId = strippedurl;
console.log("resourceId");
console.log(resourceId);
}
// assign our verb
var verb = request.method.toLowerCase();
// assign our RFC 1123 date
var date = UTCstring.toLowerCase();
// parse our master key out as base64 encoding
var key = CryptoJS.enc.Base64.parse(mastKey);
console.log("key = " + key);
// build up the request text for the signature so can sign it along with the key
var text = (verb || "").toLowerCase() + "\n" +
(resType || "").toLowerCase() + "\n" +
(resourceId || "") + "\n" +
(date || "").toLowerCase() + "\n" +
"" + "\n";
console.log("text = " + text);
// create the signature from build up request text
var signature = CryptoJS.HmacSHA256(text, key);
console.log("sig = " + signature);
// back to base 64 bits
var base64Bits = CryptoJS.enc.Base64.stringify(signature);
console.log("base64bits = " + base64Bits);
// format our authentication token and URI encode it.
var MasterToken = "master";
var TokenVersion = "1.0";
auth = encodeURIComponent("type=" + MasterToken + "&ver=" + TokenVersion + "&sig=" + base64Bits);
console.log("auth = " + auth);
// set our auth token enviornmental variable.
postman.setEnvironmentVariable("authToken", auth);
PHP CODE
#PHP Script
function generateAuthKey($url, $method){
$key = "****************";
$date = new DateTime('');
$date = $date->format('D, d M Y H:i:s O');
$ressourcetype = "";
$strippedurl = parse_url($url, PHP_URL_PATH);
$strippedparts = explode("/", $strippedurl);
$strippedurlcount = sizeof($strippedparts)-1;
#GET RESSOURCE TYPE
if ($strippedurlcount % 2){
$resType = $strippedparts[$strippedurlcount];
if ($strippedurlcount > 1){
$ressourcetype = $strippedparts[$strippedurlcount];
}
}
else{
$ressourcetype = $strippedparts[$strippedurlcount-1];
}
$sig = nl2br(strtolower($method)."\n".strtolower($ressourcetype)."\n".$strippedurl."\n".strtolower($date)."\n".""."\n");
$sig = utf8_encode($sig);
$key64 = base64_encode($key);
echo $key64."\n";
$hmac = hash_hmac('sha256',$sig,$key64);
$token = "type=master&ver=1.0&sig=".$hmac;
return urlencode($token);
}
How can I change the PHP script to provide the same output as Postman (JS)?
I believe the issue is with the following line of code:
$key64 = base64_encode($key);
As per the REST API documentation, you should be doing a base64_decode of your key as the key is already base64 encoded.
Please try by changing your code to:
$key64 = base64_decode($key);
While this is an old question, I'll note a couple issues with the author's code:
It was not necessary to utf8_encode() -- trying to utf8 encode a string that is already valid ISO-8859-1 can produce unexpected results. Also note that this method is deprecated as of PHP 8.2.0.
The author was returning the string output of hash_hmac(), rather than binary.
Here is how you correctly generate a signature in PHP. Bear in mind that all requests to the Cosmos REST API, needs to include an x-ms-date header, which matches the same date used to generate the token. It's up to you how to want to handle that, but in my case, I chose to return the date and token as an array from the function. You could also consider making the function return the entire header array all at once.
I am using Carbon and Guzzle in this example.
private function cosmosAuth(string $method, string $resourceType, string $resourceLink)
{
$date = Carbon::now()->toRfc7231String();
// alternatively: gmdate('D, d M Y H:i:s T')
$key = base64_decode(MY_COSMOS_KEY);
$body = $method . "\n" .
$resourceType . "\n" .
$resourceLink . "\n" .
$date . "\n" .
"\n";
$hash = hash_hmac('sha256', strtolower($body), $key, true);
$signature = base64_encode($hash);
$tokenType = "master";
$tokenVersion = "1.0";
$token = urlencode("type={$tokenType}&ver={$tokenVersion}&sig={$signature}");
return [
'date' => $date,
'token' => $token
];
}
Here is an example of a request to update a document. Note if you created your collection with a partition key, you also must pass the partition key value in the x-ms-documentdb-partitionkey header. The format here is a little janky, with Microsoft expecting a string representation of an array containing the value.
$resource = "dbs/{$database}/colls/{$collection}/docs/{$documentId}";
$auth = $this->cosmosAuth("PUT", "docs", $resource);
try {
$client = new \GuzzleHttp\Client();
$client->request("PUT", "https://{$account}.documents.azure.com/{$resource}", [
'headers' => [
'authorization' => $auth['token'],
'x-ms-date' => $auth['date'],
'x-ms-documentdb-partitionkey' => '["'.$documentId.'"]',
],
'json' => $documentData
]);
}
catch (Exception $e) {
$this->log("error: {$e->getMessage()}");
}
Microsoft resources:
Constructing a hashed token
Replace a document
Related
I need help translating this Node.js code into PHP. It's basically for authenticating whether the signature on my header is the same as the signature that has been encoded using base64 and HMAC-SHA256.
const crypto = require('crypto');
const timestamp = "1570350275357";
const msg = {'key1':'world','key2':'world'};
const secretKey = "mysecretsecret";
const decodedKey = Buffer.from(secretKey, 'base64').toString('utf8');
const signature = crypto.createHmac('SHA256', decodedKey).update(timestamp + '.' + msg).digest('base64');
const signatureHeader = "+DCfT1wIMUiaZnlZB4u59/d5wkXKA89lv67Ov66vnyc=";
assert(signature === signatureHeader);
So I have my request body which is {'key1':'world','key2':'world'} , the timestamp (x-duda-signature-timestamp header) is 1570350275357 and a secret key which is mysecretsecret.
It also calculates for my signature base64(hmac-sha256(secret-key, timestamp + "." + message)) which results in +DCfT1wIMUiaZnlZB4u59/d5wkXKA89lv67Ov66vnyc= , which is also the value to be found on this x-duda-signature header of the request.
I've already tried putting the pieces together on PHP and it just won't return a signature that is similar to my signature header which is +DCfT1wIMUiaZnlZB4u59/d5wkXKA89lv67Ov66vnyc=.
$getHeaders = apache_request_headers();
$timestamp = "1570350275357";
$signatureHeader = "+DCfT1wIMUiaZnlZB4u59/d5wkXKA89lv67Ov66vnyc=";
$secretKey = "mysecretsecret";
//Request Body
$message = '{'key1':'world','key2':'world'}';
$signature = base64_encode(hash_hmac('sha256',$timestamp.".".$message,$secretKey));
print_r($signature); // the value should be equal to ```$signatureHeader```
Any idea on where I went wrong? Thanks in advance!
I'm trying to encode a message in flutter and to verify it with some php code, but somehow there are some differences in the hmac encoding. The header and payload values are the same in both cases. But somehow there are some differences between the resulting values.
Any help would be very helpful, I'm stuck on this for some days now.
$base64UrlHeader = 'header';
$base64UrlPayload = 'payload';
$secret = 'secret';
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true); // 9b491a7aa29955d9d67e302965665ba0cfa4306c00470f8946eb6aa67f676595
$base64UrlSignature = base64UrlEncode($signature); // sYql52zk6tqYeGSUsDv_219UtgpK3c8-TMuko4n_L5Q
function base64UrlEncode($text) {
return str_replace(
['+', '/', '='],
['-', '_', ''],
base64_encode($text)
);
}
And this is my dart code:
// This values are obtained by using the _base64UrlEncode method from below,
// I just wrote the values directly here not to clutter with code
var base64UrlHeader = 'header';
var base64UrlPayload = 'payload';
/// SIGNATURE
var secret = utf8.encode('secret');
var message = utf8.encode(base64UrlHeader + '.' + base64UrlPayload);
var hmac = new Hmac(sha256, secret);
var digest = hmac.convert(message); // b18aa5e76ce4eada98786494b03bffdb5f54b60a4addcf3e4ccba4a389ff2f94
var signature = _base64UrlEncode(digest.toString()) // YjE4YWE1ZTc2Y2U0ZWFkYTk4Nzg2NDk0YjAzYmZmZGI1ZjU0YjYwYTRhZGRjZjNlNGNjYmE0YTM4OWZmMmY5NA
// This method is used to obtain the header, payload and signature
static String _base64UrlEncode(String text) {
Codec<String, String> stringToBase64Url = utf8.fuse(base64url);
return stringToBase64Url
.encode(text)
.replaceAll('=', '')
.replaceAll('+', '-')
.replaceAll('/', '_');
}
Both the header and payload are obtained from the same json object,
I'm trying to get this working in flutter and i cant get the same outcome.
My php code prints a diffrent hash then my flutter code. Is it posible to do this in a flutter app?
i have tried to achieve this by running this flutter code. But after 5 hours of reading i gave up and created a stack overflow account.
import 'package:crypto/crypto.dart';
import 'dart:convert'; // for the utf8.encode method
import 'package:http/http.dart' as http;
void main() {
var api = 'https://app.repricer.nl';
var endpoint = '/api/v1/channels/all.json';
var method = 'GET';
var public_key = '';
var private_key = '';
var data = '';
var ms = (new DateTime.now()).millisecondsSinceEpoch;
var timestamp = ms / 1000;
var hash_string = public_key + '|' + method + '|' + endpoint + '|' + data + '|' + timestamp.toString();
var key = utf8.encode(private_key);
var bytes = utf8.encode(hash_string);
var hmacSha256 = new Hmac(sha512, key); // HMAC-SHA256
var digest = hmacSha256.convert(bytes);
print(digest);
}
This is the PHP code that i want to convert to flutter:
$api = 'https://app.repricer.nl';
$endpoint = '/api/v1/channels/all.json';
$method = 'GET';
$public_key = '';
$private_key = '';
// Generate the CURL headers to authenticate our request
$headers = generateHash($public_key, $private_key, $method, $endpoint, $data);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL,$api.$endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$result = curl_exec($ch);
curl_close($ch);
print_r($result);
function generateHash($public_key, $private_key, $method, $endpoint, $data)
{
$timestamp = date("U");
$hash_string = array($public_key,$method,$endpoint,$data,$timestamp);
$hash = hash_hmac('sha512',implode('|',$hash_string),$private_key);
print ($hash);
return array('X-Auth: '.$public_key, 'X-Hash: '.$hash, 'X-Date: '.$timestamp);
}
I expect the output is the same exept from the timestamp. But i ran it in the same second and it are 2 completly diffrent outcomes.
Your code is correct.
Small fix is just replacing:
ms / 1000 to (ms / 1000).toInt()
I don't see other flaw in Your code.
I've came to that answer after doing test run with constant timestamp parameter: 1572731120
PHP:
$timestamp = 1572731120; //date("U");
$hash_string = array($public_key,$method,$endpoint,$data,$timestamp);
$hash = hash_hmac('sha512',implode('|',$hash_string),$private_key);
Dart
var ms = (new DateTime.now()).millisecondsSinceEpoch;
var timestamp = 1572731120;//(ms / 1000).toInt();
var hash_string = public_key + '|' + method + '|' + endpoint + '|' + data + '|' + timestamp.toString();
var key = utf8.encode(private_key);
var bytes = utf8.encode(hash_string);
var hmacSha256 = new Hmac(sha512, key); // HMAC-SHA256
var digest = hmacSha256.convert(bytes);
which proves that results are equal:
I am trying to authenticate a Microsoft Teams custom Bot with PHP, following the Microsoft instructions and read de C# example code.
Microsoft Intructions steps:
1. Generate the hmac from the request body of the message. There are standard libraries on most platforms. Microsoft Teams uses standard
SHA256 HMAC cryptography. You will need to convert the body to a byte
array in UTF8.
2. To compute the hash, provide the byte array of the shared secret.
3. Convert the hash to a string using UTF8 encoding.
4. Compare the string value of the generated hash with the value provided in the HTTP request.
I had write a small php script to test this in local:
<?php
//Function to generate C# byte[] equivalent
function unpak_str($val){
$b = unpack('C*', $val);
foreach ($b as $key => $value)
$byte_a .= $value;
return $byte_a;
}
//multi test outputs
function hasher($values=[], &$output){
//my secret share
$secret="ejWiKHgsKY1ZfpJwJ+wIiN4+bgsFad/lkpu9/MWNXgM=";
//diferent test
$secret_64=base64_decode($secret);
$secret_b=unpak_str($secret);
$secret_b_64=unpak_str(base64_decode($secret));
foreach($values as $msg){
$hs = hash_hmac("sha256",$msg,$secret, true);
$hs_64 = hash_hmac("sha256",$msg,$secret_64, true);
$hs_b = hash_hmac("sha256",$msg,$secret_b, true);
$hs_b_64 = hash_hmac("sha256",$msg,$secret_b_64, true);
$output.=base64_encode($hs)." <BR>";
$output.=base64_encode($hs_64)." <BR>";
$output.=base64_encode($hs_b)." <BR>";
$output.=base64_encode($hs_b_64)." <BR>";
}
}
//Get data
$data=file_get_contents('php://input');
//real data request content for test
$data ='{type":"message","id":"1512376018086","timestamp":"2017-12-04T08:26:58.237Z","localTimestamp":"2017-12-04T09:26:58.237+01:00","serviceUrl":"https://smba.trafficmanager.net/emea-client-ss.msg/","channelId":"msteams","from":{"id":"29:1aq6GCrC6lM9dv3YkAYi1gxTPiLnojGFgVr0_Th-2x6DhqmHAOhFwQHFzSyDy5RruXY4_FZjJebKHU7bpxfHpXA","name":"ROBERTO ALONSO FERNANDEZ","aadObjectId":"1e0dc7a0-9d5e-488b-bcf2-7e39c84076b8"},"conversation":{"isGroup":true,"id":"19:9e1c52275dfb4d0b873ddf34eb9f4979#thread.skype;messageid=1512376018086","name":null},"recipient":null,"textFormat":"plain","attachmentLayout":null,"membersAdded":[],"membersRemoved":[],"topicName":null,"historyDisclosed":null,"locale":null,"text":"<at>PandoBot</at> fff","speak":null,"inputHint":null,"summary":null,"suggestedActions":null,"attachments":[{"contentType":"text/html","contentUrl":null,"content":"<div><span itemscope=\"\" itemtype=\"http://schema.skype.com/Mention\" itemid=\"0\">PandoBot</span> fff</div>","name":null,"thumbnailUrl":null}],"entities":[{"type":"clientInfo","locale":"es-ES","country":"ES","platform":"iOS"}],"channelData":{"teamsChannelId":"19:9e1c52275dfb4d0b873ddf34eb9f4979#thread.skype","teamsTeamId":"19:1e04f564ce5e4596bf2f266dbcff439e#thread.skype","channel":{"id":"19:9e1c52275dfb4d0b873ddf34eb9f4979#thread.skype"},"team":{"id":"19:1e04f564ce5e4596bf2f266dbcff439e#thread.skype"},"tenant":{"id":"9744600e-3e04-492e-baa1-25ec245c6f10"}},"action":null,"replyToId":null,"value":null,"name":null,"relatesTo":null,"code":null}';
//generate HMAC hash with diferent $data formats
$test = [$data, unpak_str($data), base64_encode($data), unpak_str(base64_encode($data))];
hasher($test, $output);
//microsoft provided HMAC
$output.="<HR>EW2993goL1q7nGhytIb3jKmV6luXLz15Bq2aYwuCeiE=";
echo $output;
/*
Calculates:
0HsKoHza/QBvdz+nZw9tOti/eSWjyMMt/U77bfDqiE8=
3jSq3I0HNQkjB9QfnnsxC1c3pF5PjqweHlSVcicrShY=
bTQcGVTHX8/Gh4xovnN0WiJUiNaOQwvUZnwyFfiCaJE=
qHBT2Y2ITyoxz2gmBbG8P1CrClvETus6dTffET3bAR8=
8BcrXEQDDi77qgxCZLYyb/6ez8p9Qg2ZhTyZPWkdn/g=
+8RSU5SSJKxqRLKkI+NkTE01xwu6PwPkKKMuvyyUvlo=
PdL5ZpEwcN6Fe5kfX7zeAZLJvt0uLNTzu7lhuoOcr2o=
s6M5pYruEgWeNMEOFfQRjVKQqtPBVaW3TJb2MzObF2c=
xOTLhddbAwczQVneuTDQhPzmoIXGQljpf27c+hlhQII=
aUMm5b2sKfmwGZOglfiu228fWqoLlwjc7z1QRdIbakE=
5a7bAj9tzqhP9l85OvfVasURW0GSV5rykRutFFPO2fk=
kwg6P2LoDL9rc3SSwJxQeoYJzZYlh+FHFefe38UokBM=
eHeAzI7TV6vYDzxTxwyKWxMeVKFiFlIffWRiIMAk6fk=
ZCyj2UppacQOTXogLPMFLDeMArQg03rhhlIwhynDvng=
uQYK+7u9fppb62zXqtVYfkNK9wVawB3g+BlTyu4dc74=
vjOFA3fqpwUx/VO9dQv3XviNhpjTNQsUwaJIwH4JjdY=
------------ MS PROVIDED HMAC ---------------
EW2993goL1q7nGhytIb3jKmV6luXLz15Bq2aYwuCeiE=
*/
I've zero hash matching...
Finally after lots of trial, it maked me crazy and decided to start a new bot with a new secret. Now works fine. I'm human while MS Teams no... I suppos that was my fault with copy/paste but is a really stranger thing and the other hand old bot fails a lot of times with no response and the newest no
Full example validation HMAC in PHP for Microsoft Teams Custom Bot:
<?php
//The secret share with Microsoft Teams
$secret="jond3021g9imMkrt8txF5AVPIwPFouNV/I72cQFii18=";
//get headers
$a = getallheaders();
$provided_hmac=substr($a['Authorization'],5);
//Get data from request
$data=file_get_contents('php://input');
//json decode into array
$json=json_decode($data, true);
//hashing
$hash = hash_hmac("sha256",$data,base64_decode($secret), true);
$calculated_hmac = base64_encode($hash);
//start log var
$log = "\n========".date("Y-m-d H:i:s")."========\n".$provided_hmac."\n".$calculated_hmac."\n";
try{
//compare hashs
if(!hash_equals($provided_hmac,$calculated_hmac))
throw new Exception("No hash matching");
//response text
$txt="Hi {$json["from"]["name"]} welcome to your custom bot";
echo '{
"type": "message",
"text": "'.$txt.'"
}';
$log .= "Sended: {$txt}";
}catch (Exception $e){
$log .= $e->getMessage();
}
//write log
$fp = fopen("log.txt","a");
fwrite($fp, $log . PHP_EOL);
fclose($fp);
I'm not a PHP expert, and your logic to cover all the cases is a bit convoluted, but I'm pretty sure your problem is that you aren't converting the message ($data) from UTF8 before computing the HMAC.
Here's a simple custom echo bot in Node that shows how to compute and validate the HMAC:
const util = require('util');
const crypto = require('crypto');
const sharedSecret = "+ZaRRMC8+mpnfGaGsBOmkIFt98bttL5YQRq3p2tXgcE=";
const bufSecret = Buffer(sharedSecret, "base64");
var http = require('http');
var PORT = process.env.port || process.env.PORT || 8080;
http.createServer(function(request, response) {
var payload = '';
request.on('data', function (data) {
// console.log("Chunk size: %s bytes", data.length)
payload += data;
});
request.on('end', function() {
try {
// Retrieve authorization HMAC information
var auth = this.headers['authorization'];
// Calculate HMAC on the message we've received using the shared secret
var msgBuf = Buffer.from(payload, 'utf8');
var msgHash = "HMAC " + crypto.createHmac('sha256', bufSecret).update(msgBuf).digest("base64");
console.log("Computed HMAC: " + msgHash);
console.log("Received HMAC: " + auth);
response.writeHead(200);
if (msgHash === auth) {
var receivedMsg = JSON.parse(payload);
var responseMsg = '{ "type": "message", "text": "You typed: ' + receivedMsg.text + '" }';
} else {
var responseMsg = '{ "type": "message", "text": "Error: message sender cannot be authenticated." }';
}
response.write(responseMsg);
response.end();
}
catch (err) {
response.writeHead(400);
return response.end("Error: " + err + "\n" + err.stack);
}
});
}).listen(PORT);
console.log('Listening on port %s', PORT);
You don't need unpack(), or that unpak_str() function (which is also broken because it just overwrites each byte with the next one, not appending them).
Byte arrays are not a thing in PHP - the language doesn't have different string types; how strings are interpreted is entirely up to the functions using them. That is, your shared secret should be just the result of base64_encode($secret).
We have a large number of videos/audo/media hosted on a custom domain on S3 and have created a set of functions in order to sign the URLs and allow them to be both streamable and downloadable. The problem is that the signed URL of course never works. The error is:
The request signature we calculated does not match the signature you provided. Check your key and signing method.
Of course if we take the bytecode returned from this page and input it into the Amazon S3 Signature Tester and grab the bytecode from there it works just fine. Even if the string to sign from our function as well as the decoded byte code in the Signature Tester are identical, it never works.
It's called via a small block of PHP code:
$headers = createS3parameters($expiry, $file_type);
$request = preg_replace("/^.*?:\/\/.*\//", "/", $bucketurl);
$signature = signRequest($request, $expiry, $s3secret, $headers, "GET", $file_type);
$signed_request = "$bucketurl?AWSAccessKeyId=$s3key&Expires=$expiry&$headers&Signature=$signature";
This is the function which actually signs it.
function signRequest($request, $expiration, $s3secret, $headers = '', $type = 'GET', $content_type = 'default')
{
if ($expiration == 0 || $expiration == null)
{
$expiration = time() + 315576000; // 10 years (never)
}
if (strcmp($content_type, 'default') == 0)
{
$content_type = "";
}
// S3 tester spits out this format
/*$string = "$type\n".
"\n\n".
"$expiration\n".
"/mybucket$request?$headers";*/
$string = "$type\n".
"\n".
"$content_type\n".
"$expiration\n".
"$headers\n".
"$request";
// must be in UTF8 format
$string = utf8_encode(trim($string));
// encode to binary hash using sha1. require S3 bucket secret key
$hash = hash_hmac("sha1",$string, $s3secret,false);
// sha1 hash must be base64 encoded
$hash = base64_encode($hash);
// base64 encoded sha1 hash must be urlencoded
$signature = rawurlencode($hash);
return $signature;
}
Which then creates a URL such as:
http://mybucket.s3.amazonaws.com/My_Boring_Video.wmv?AWSAccessKeyId=AKIAIEXAMPLE6GA3WYQ&Expires=1344160808&response-content-type=application/force-download&response-expires=1344160808&Signature=OTIxOTI0YjNjMTA1NjMyNmJjYTk0MGE2YWJkMmI5OWQ3MGM2ZGY0MQ%3D%3D
Which unfortunately doesn't work. Is there an obvious problem here I've been staring at far too long to properly figure out?
UPDATE: Specs are specs, but are only help if they match actual practice.
Amazon's S3 specs say the signature should be formed as the following:
Signature = URL-Encode( Base64( HMAC-SHA1( YourSecretAccessKeyID, UTF-8-Encoding-Of( StringToSign ) ) ) );
StringToSign = HTTP-VERB + "\n" +
Content-MD5 + "\n" +
Content-Type + "\n" +
Expires + "\n" +
CanonicalizedAmzHeaders +
CanonicalizedResource;
HOWEVER the actual request needed looks like this:
StringToSign = HTTP-VERB + "\n" +
"\n" +
"\n" +
Expires + "\n" +
Bucket + CanonicalizedResource + "?" + CanonicalizedAmzHeaders;
Strangely enough, in PHP you also can't seem to do this:
$string = "$type\n".
"\n".
"\n".
"$expiration\n".
"/$bucket$request?$headers";
It changes the signature and ends up rejected so must all be on a single line. I haven't gone as far as checking whether this is a bug in our particular version of PHP or just a general PHP bug (or maybe intended functionality??!). You must also include the name of your bucket even if you are using the vanity URLs available such as mybucket.s3.amazonaws.com or mybucket.mydomain.com. The documentation doesn't specify what you can or can't do and I made the assumption that since we are using an S3 based vanity URL it (S3) would pick up on the domain name and translate it to the bucket.
I ended up changing my function to be the following:
function signRequest($bucket, $request, $expiration, $s3secret, $headers = '', $type = 'GET', $content_type = 'default')
{
if ($expiration == 0 || $expiration == null)
{
$expiration = time() + 315576000; // 10 years (never)
}
if (strcmp($content_type, 'default') == 0)
{
$content_type = "";
}
$headers = trim($headers, '&');
// This is the spec:
/*$string = "$type\n".
"\n".
"$content_type\n".
"$expiration\n".
"$headers\n".
"$bucket$request";*/
// but it will only work as this
$string = "$type\n\n\n$expiration\n/$bucket$request?$headers";
// this could be a single line of code but left otherwise for readability
// must be in UTF8 format
$string = utf8_encode(trim($string));
// encode to binary hash using sha1. require S3 bucket secret key
$hash = hash_hmac("sha1",$string, $s3secret,true);
// sha1 hash must be base64 encoded
$hash = base64_encode($hash);
// base64 encoded sha1 hash must be urlencoded
$signature = urlencode($hash);
return $signature;
}
Hopefully someone else finds this useful as well.
UPDATE (20180109): Adding in the function that calls this (aka let's make this blatently simple).
It helps understanding of what to pass to the signRequest() function.
private function genS3QueryString($bucketurl)
{
$file_type = 'application/force-download';
$expiry = '1831014000'; //Sun, Jan 09 2028 0700 UTC
$headers = '&response-content-type='.$file_type.'&response-expires='.$expiry;
$bucket = preg_replace("/^.*?:\/\/(.*)\.s3\.amazonaws\.com\/.*/", "$1", $bucketurl);
$request = preg_replace("/^.*?:\/\/.*\//", "/", $bucketurl);
$signature = $this->signRequest($bucket, $request, $expiry, S3_SECRET_KEY, $headers, 'GET', $file_type);
$signed_request = '?AWSAccessKeyId='.S3_KEY.'&Expires='.$expiry.$headers.'&Signature='.$signature;
return $signed_request;
}
You'll note however the first function only generates the encrypted signature section required to attach to a GET request for AWS. As per the docs (see the link far above) more is required for the request, as is formed from the second function just above.
The expiry time I believe can be a rolling expiry time if you so desire but should be sufficiently in the future that the asset becomes outdated and replaced long before the actual expiry time. An arbitrary time was chosen to sufficiently outlast the current version of the website.
The second function only requires the bucket url of the protected asset. The desired response type could be added to the call or simply changed so that asset (in this case just non-displayable) documents are downloaded as a different type.
The signed_request that is then returned must then be appended back onto the bucketurl for a working URI to request from a protected S3 asset.