I could use some help with implementing the JSON Web Signature (JWS) and JSON Web Key (JWK) portions of the ACME protocol with PHP.
My specific questions/issues are:
The ACME documentation suggests
ES256
as the algorithm, buthash_hmac
doesn't seem to have that as an option. What function in PHP can useES256
--OR-- what option will ACME allow thathash_hmac
can create?What values are acceptable for creating a JSON Web Key? Is this a cryptographic function, or a random string of text? What is the lifespan of the JWK value? ie. is it a "password" that needs to be used for years, for the whole life of the ACME account? Or is it disposable for each session?
Does the
$key
, third argument tohash_hmac
, need to correlate with the JWK in some way, or are they separate and arbitrary? What is the lifespan of the hash key? ie. is it a key that needs to be used for years, for the whole life of the ACME account? Or is it disposable for each session?What function(s) should be used to generate the
hash_hmac
key value and the JWK values?Does
openssl_sign()
withOPENSSL_ALGO_SHA256
implementES256
(ECDSA
)?
Here is the skeleton code that is successfully making requests but getting "malformed JWS" errors:
public function CreateAccount(){
$Content = $this->PrepareAccountRequest();
$this->DoAccountAPICall($Content);
}
private function PrepareAccountRequest(){
$Protected = $this->Base64URL(json_encode([
"alg" => "ES256",
"jwk" => 'whatisthis',
"nonce" => $this->nonce,
"url" => $this->aEndpoints['newAccount']
]));
$Payload = $this->Base64URL(json_encode([
"termsOfServiceAgreed" => true,
"contact" => [
"mailto:[email protected]",
"mailto:[email protected]"
]
]));
$Signature = hash_hmac('sha256', $Protected . "." . $Payload, 'fsdjjkdlsdjlkasdf', false);
$Content = json_encode([
'protected' => $Protected,
'payload' => $Payload,
'signature' => $Signature
]);
return $Content;
}
private function DoAccountAPICall($Content){
$ch = curl_init();
$Headers = [
'Cache-Control: no-cache',
'Content-Type: application/jose+json',
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $Headers);
curl_setopt($ch, CURLOPT_URL, $this->aEndpoints['newAccount']);
curl_setopt($ch, CURLOPT_HEADER, false); // Don't include header in the output.
curl_setopt($ch, CURLOPT_POST, true); // Do a POST Request.
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Output the response from curl_exec
curl_setopt($ch, CURLOPT_HEADERFUNCTION, array($this, 'HandleHeaderLine'));
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); // Timeout connect at five seconds
curl_setopt($ch, CURLOPT_DNS_CACHE_TIMEOUT, 300); // Cache DNS for 5 minuites.
curl_setopt($ch, CURLOPT_POSTFIELDS, $Content);
$Response = curl_exec($ch);
if(curl_error($ch)) {
echo 'A CURL Error Occured.'.curl_error($ch);
}
curl_close($ch);
echo '<h1>Headers</h1>';
echo PP($this->aLastHeaders);
echo '<h1>Response</h1>';
echo $Response;
echo '<h1>JSON Response</h1>';
$aJSONResponse = json_decode($Response,true);
PP($aJSONResponse);
}
private function Base64URL($Input){
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($Input));
}
private $aLastHeaders = array();
private function HandleHeaderLine( $curl, $header_line ) {
$this->aLastHeaders[] = $header_line;
return strlen($header_line);
}
UPDATE:
I think I'm close to making this work, but I don't think the parts are fitting together quite right.
public function CreateKeys(){
// Create a new private key
$this->oOpenSSLAsymmetricKey = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1'
]);
// Get key details
$aKeyDetails = openssl_pkey_get_details($this->oOpenSSLAsymmetricKey);
$aJSONKeyDetails = [
'kty' => 'EC',
'crv' => 'P-256',
'alg' => 'ES256',
'use' => 'sig',
'x' => base64_encode($aKeyDetails['ec']['x']),
'y' => base64_encode($aKeyDetails['ec']['y']),
'd' => base64_encode($aKeyDetails['ec']['d'])
];
$this->JWK = json_encode($aJSONKeyDetails);
echo $this->JWK;
}
This outputs:
{
"kty":"EC",
"crv":"P-256",
"alg":"ES256",
"use":"sig",
"x":"mlX7B4E9POpWAhnyXJxW+xA7CbjJAXp5GVAYAkKZB\/4=",
"y":"5HMng\/ZKHEcIM5\/srINSugx1pmTR5hhidIvJaTno5VU=",
"d":"LoksxPi5A45IyBqECoXW18B1Ld6yvYAFpSooleX4UyU="
}
Which looks OK, and I think it should go where it says whatisthis
in the PrepareAccountRequest()
function above.
I've also added a signing function:
public function Sign($Content){
// Sign the text.
$BinarySignature = '';
openssl_sign($Content, $BinarySignature, $this->oOpenSSLAsymmetricKey, OPENSSL_ALGO_SHA256);
// Test the signature using the public key.
// Returns 1 if the signature is correct, 0 if it is incorrect, and -1 or false on error.
if(1 === openssl_verify($Content, $BinarySignature, $this->sPublicKey, OPENSSL_ALGO_SHA256)){
//echo 'Signature Confirmed!';
return $BinarySignature;
}else{
//echo 'Signature FAILED!';
return false;
}
}
Which is returning binary. Now the line from before which read:
$Signature = hash_hmac('sha256', $Protected . "." . $Payload, 'fsdjjkdlsdjlkasdf', false);
gets replaced with:
$Signature = $this->Base64URL($this->Sign($Protected . "." . $Payload));
$Encoded = $this->Base64URL($Protected.$Payload.$Signature);
echo $Encoded;
Which produces:
ZXlKaGJHY2lPaUpGVXpJMU5pSXNJbXAzYXlJNkludGNJbXQwZVZ3aU9sd2lSVU5jSWl4Y0ltTnlkbHdpT2x3aVVDMHlOVFpjSWl4Y0ltRnNaMXdpT2x3aVJWTXlOVFpjSWl4Y0luVnpaVndpT2x3aWMybG5YQ0lzWENKNFhDSTZYQ0pTSzFReE9XWTRSR2hPWmtkcVN6WkZOM0l4ZW5WUlVYbGxlVzlUTTJWMGVHWjBWMVJZTkUxbEt5dHpQVndpTEZ3aWVWd2lPbHdpZHpkQ1VWeGNYQzlZYmxReGVFTm1jMmxVT0Z4Y1hDOWxSVGt3TnpONE1qWm1aa2hCTW5WRVFXZFFja2w0YTBKRE1EMWNJaXhjSW1SY0lqcGNJa2hxWW1sWFdUVXpkbTExVDNKdlNXWk5jVmdyTTI1eE4zQTViMWwzYjNOdFN6UnhNVGw2UTBsSlF6UTlYQ0o5SWl3aWJtOXVZMlVpT2lJelRUTjRPWFE0VURaeFZXdEZhVVV4TlRsaWIweEdNV2xhTkRGRWJsSlhaMGc1Vm5sT2JXWlFSRlF6VmpoSmJFZFFhRFFpTENKMWNtd2lPaUpvZEhSd2N6cGNMMXd2WVdOdFpTMXpkR0ZuYVc1bkxYWXdNaTVoY0drdWJHVjBjMlZ1WTNKNWNIUXViM0puWEM5aFkyMWxYQzl1WlhjdFlXTmpkQ0o5ZXlKMFpYSnRjMDltVTJWeWRtbGpaVUZuY21WbFpDSTZkSEoxWlN3aVkyOXVkR0ZqZENJNld5SnRZV2xzZEc4NlkyVnlkQzFoWkcxcGJrQmxlR0Z0Y0d4bExtOXlaeUlzSW0xaGFXeDBienBoWkcxcGJrQmxlR0Z0Y0d4bExtOXlaeUpkZlFNRVVDSUZUVDVHSmRpd2lGdWZHZlZ1VjFFdWVIQWY3TDJzOUxRNzgzb1ZTYkljMHhBaUVBdm9MSXo0NXdlSG1pVmo3NW02NDR4MWtlWjY2bHpPZllGdlcyMlFhcDhTZw
The ACME API still complains Parse error reading JWS
.
I think this is looking mostly right, but I'm not sure I have all the Base64
vs Base64Web
right and also, all the JSON encoding and base64 encoding and concatenations in the right order.