PHP, newAccount request, parse error reading JWS

I'm trying to get a new ACME client working with PHP. I think I'm close but something is not quite right.

This is a request that was generated by the script (my unit test generates new keys on each run):

{"protected":"eyJhbGciOiJFUzI1NiIsImp3ayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwiYWxnIjoiRVMyNTYiLCJ1c2UiOiJzaWciLCJ4IjoiaTVPVWhCUWRsTHhLMHpFM2ttRGVEWGJSdjE0UnFPSU94b1o1ak9RM3FZWT0iLCJ5IjoiN1RPQlNmYjJxQWxyeVVBWVUzUnUwXC82RlZGaHh4NjJ2N3FVV3dCQ1lpVnc9IiwiZCI6Ilo5OExJRUZYcUlJTHd1bno5VlZwV3BPWjhLVEpcL2o0NnFLOHdNV3hLNVRjPSJ9LCJub25jZSI6IjNNM3g5dDhQaGxacGhlUjg5OTU2ZVF4VGdFYWwxNHZLdVNkZ2ljanJQUm41TmxTQ2xpWSIsInVybCI6Imh0dHBzOlwvXC9hY21lLXN0YWdpbmctdjAyLmFwaS5sZXRzZW5jcnlwdC5vcmdcL2FjbWVcL25ldy1hY2N0In0","payload":"eyJ0ZXJtc09mU2VydmljZUFncmVlZCI6dHJ1ZSwiY29udGFjdCI6WyJtYWlsdG86Y2VydC1hZG1pbkBleGFtcGxlLm9yZyIsIm1haWx0bzphZG1pbkBleGFtcGxlLm9yZyJdfQ","signature":"MEUCIDCOgXGbLsRRFHrOT-FGfhQHayO5iwywfwZqbCRNL4lfAiEAk4Lb6tFGhe9h6KrMg9PyrrLROqxWuwWKYujeNQbfqaM"}

The response is:

{
  "type": "urn:ietf:params:acme:error:malformed",
  "detail": "Parse error reading JWS",
  "status": 400
}

Here's the relevant PHP code:

   public function CreateAccount(){
      
      $this->CreateKeys();
      $Content = $this->PrepareAccountRequest();
      $this->DoAccountAPICall($Content);
   }
 private function CreateKeys(){
      
      // Create a new private key
      $this->oOpenSSLAsymmetricKey = openssl_pkey_new([
         'private_key_type' => OPENSSL_KEYTYPE_EC,
         'curve_name' => 'prime256v1'
      ]);
      
      // Export a human readable key
      openssl_pkey_export($this->oOpenSSLAsymmetricKey,$this->sPrivateKey);
      
      // Get key details
      $aKeyDetails = openssl_pkey_get_details($this->oOpenSSLAsymmetricKey);
      $this->sPublicKey = $aKeyDetails['key'];
      
      $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 = $aJSONKeyDetails;  
   }
private function PrepareAccountRequest(){
   
      $Protected = $this->Base64URL(json_encode([
         "alg" => "ES256", 
         "jwk" => $this->JWK,
         "nonce" => $this->nonce,
         "url" => $this->aEndpoints['newAccount']
      ]));
      
      $Payload = $this->Base64URL(json_encode([
         "termsOfServiceAgreed" => true,
         "contact" => [
            "mailto:cert-admin@example.org",
            "mailto:admin@example.org"
         ]
      ]));
      
      $Signature = $this->Base64URL($this->Sign($Protected.'.'.$Payload));
      
      $Content = json_encode([
         'protected' => $Protected,
         'payload' => $Payload,
         'signature' => $Signature
      ]);      

      return $Content;
   }
private function Sign($Content){
    
      $BinarySignature = ''; // If the call was successful the signature is returned in $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)){         
         return $BinarySignature;
      }else{         
         return false;
      }
   }
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')); // Store Header details.
      
      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 Occurred.'.curl_error($ch);
      }
      curl_close($ch);
      
      echo '<h1>JSON Response</h1>';
      $aJSONResponse = json_decode($Response,true);
      var_dump($aJSONResponse);
   }
   private function Base64URL($Input){
      return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($Input));      
   }

I think it's helpful to break problems down and look at one thing at a time.

  1. Does the header (protected) JSON below look correct?
  2. For the x and y values, openssl returns a binary value. Should this be Base64 encoded or Base64URL encoded?
  3. Should the encoded x and y always be a certain string length each time? If so, which length?
{
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "alg": "ES256",
    "use": "sig",
    "x": "I8kv-qXgdjQ66nq2nTRwrE3AQ22xBlA33BlBILDtUkY",
    "y": "DV8K4qCeP9KbX0lHjapjn5BDG40ID3M76lHcovpkV34"
  },
  "nonce": "3M3x9t8PES1HH3UTPt3Ey8hysuGVxwESewbJ5uI3ZS5KmAEi-WI",
  "url": "https://acme-staging-v02.api.letsencrypt.org/acme/new-acct"
}

I don't recall anything in the ACME spec that is not supposed to be Base64Url. The x,y values in your jwk definitely should be, but they appear to already be in your example based on the one - char.

The alg and use fields in the jwk don't need to be in there. Not 100% sure if them being there would cause a problem though. The outer alg is fine.

The length is dependent on the curve for EC keys. P-256 is the shortest, P-384 longer, and P-521 is the longest (though I'm not sure any CA supports using it at the moment). Your x,y look to be the correct length for P-256.

5 Likes

Thank you,

I found something interesting in another post from a year ago. The post was:

https://community.letsencrypt.org/t/parse-error-reading-jws/182390

The OP said he solved it, and said:

"I thought i can use the created signature from openssl directly like Base64Url(Signature). But the openssl output is ASN.1 DER encoded. The JWS Signature however must be the concatenation of EC points R and S. After extracting R and S from the output signature and use their concatenation with base64url it works. "

If that's true, it may be the issue that I'm having.

My signature is generated with:

$Signature = $this->Base64URL($this->Sign($Protected.'.'.$Payload));

and the Sign() function basically does:

$BinarySignature = ''; // The signature is returned in $BinarySignature. 
openssl_sign($Content, $BinarySignature, $this->oOpenSSLAsymmetricKey, OPENSSL_ALGO_SHA256);

So if the output of openssl_sign cannot just be Base64URL encoded and used as the JWS signature, that may be the problem... But I'm not sure what the OP meant in that other thread about concatenating the points R and S.

Does anybody know what to do with the output from openssl_sign() to convert it to the right format for JWS?

2 Likes

OpenSSL is giving that output as a ASN.1 sequence, IIRC. You can try parsing it with https://lapo.it/asn1js/ And seeing the structure.

5 Likes

When using a RSA key, the openssl_sign function returns a signature that can be passed directly to Base64Url. In case of an elliptic curve (EC) key the returned signature is indeed ASN.1 DER encoded:

Here is an example of how to convert the signature to a format which then can be passed to Base64Url:

function asn2signature($asn,$pad_len){
	if ($asn[0]!=="\x30") throw new Exception('ASN.1 SEQUENCE not found !');
	$asn=substr($asn,$asn[1]==="\x81"?3:2);
	if ($asn[0]!=="\x02") throw new Exception('ASN.1 INTEGER 1 not found !');
	$R=ltrim(substr($asn,2,ord($asn[1])),"\x00");
	$asn=substr($asn,ord($asn[1])+2);
	if ($asn[0]!=="\x02") throw new Exception('ASN.1 INTEGER 2 not found !');
	$S=ltrim(substr($asn,2,ord($asn[1])),"\x00");
	return str_pad($R,$pad_len,"\x00",STR_PAD_LEFT).str_pad($S,$pad_len,"\x00",STR_PAD_LEFT);
}
bits pad_len ceil($bits/8)
256 32
384 48
521 66

This function is part of my ACMECert library if you need more context:

1 Like

Thank you,

The asn2signature function fixed the issue. Is there any information (that doesn't get too heavy into cryptography) on what the function does?

2 Likes

The function is mostly string manipulation [not cryptography].

5 Likes

It's a hacky way of transforming DER byte strings. DER (Distinguished Encoding Rules) is a binary encoding for data structures described by ASN.1 (Abstract Syntax Notation One).

The OpenSSL output produced is in this ASN.1/DER format. However, the signature expected by RFC 7518 Section 3.4 is not in ASN.1 format: It expects the ECDSA signature values (called R and S, two 256/384/521-bit integers) to be encoded as a single big-endian unsigned integer concatenating the two. The PHP function above "searches through" the ASN.1/DER sequence to look for the bytes representing R and S respectively (they're within the DER sequence, but DER also has lots of control information in it as well). It then extracts the bytes representing R and S from the DER sequence, concatenating them into a single byte string as described by the above RFC. It's basically a really basic DER parser, designed only for a very specific ASN.1 sequence.

5 Likes

This answer explains how the parsing is done:

4 Likes

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.