OpenID (and TypeKey) using native OpenSSL functions in PHP

Update: fixed a flaw in my implementation

I may have hinted at this a couple of times before, but now I'm actually saying something useful about it... I have a patch (php-openid.diff, for PHP 5, might also apply to PHP 4) for the openssl extension that makes it easier to build OpenID and TypeKey authentication support into your PHP apps.

I don't have a canned solution for you to deploy, but I can give you some pointers on how to use these bits. I'm assuming that you know a bit about how OpenID works.

This worked for me in my tests; it's not necessarily the most optimal way to do it, but it highlights how it works.

Thanks to the folks at JanRain, there was a flaw in my implementation that is now fixed.

Associate

Association allows you to generate a relationship with an OpenID server by generating and exchanging keys. It has nothing to do with an authentication request per-se; the result of the request can be used to authenticate a user later (and other users of that same identity server). The results of the association should be cached.

If you haven't already associated with an OpenID server, you'll want to do something like the following:

<?php
  // convert openssl unsigned big endian into signed two's complement
  // notation
  function btowc($str) {
    if (ord($str[0]) > 127) {
      return "\\x00" . $str;
    }
    return $str;
  }
  $assoc = array();
  $crypto = array();
  $dh = openssl_dh_generate_key(OPENID_P_VALUE, '2');
  foreach (openssl_dh_get_params($dh) as $n => $v) {
    $crypto[$n] = openssl_bignum_to_string($v, 10);
  }
  $params = array(
     'openid.mode' => 'associate',
     'openid.assoc_type' => 'HMAC-SHA1',
     'openid.session_type' => 'DH-SHA1',
     'openid.dh_modulus' => base64_encode(
                btwoc(openssl_bignum_to_string(OPENID_P_VALUE))),
     'openid.dh_gen' => base64_encode(
                btwoc(openssl_bignum_to_string('2'))),
     'openid.dh_consumer_public' => base64_encode(btwoc(
                openssl_bignum_to_string($crypto['pub_key']))),
  );
  $r = perform_openid_rpc($server, $params); // talk to server
  if ($r['session_type'] == 'DH-SHA1') {
    $s_pub = openssl_bignum_from_bin(
               base64_decode($r['dh_server_public']));
    $dh_sec = openssl_dh_compute_key($dh, $s_pub);
    if ($dh_sec === false) {
      do {
        $err = openssl_error_string();
        if ($err === false) {
          break;
        }
        echo "$err<br>\\n";
      } while (true);
    }
    $sh_sec = sha1($dh_sec, true);
    $enc_mac = base64_decode($r['enc_mac_key']);
    $secret = $enc_mac ^ $sh_sec;
    $assoc['secret'] = $secret;
    $assoc['handle']  = $r['assoc_handle'];
    $assoc['assoc_type'] = $r['assoc_type'];
    $assoc['expires'] = time() + $r['expires_in'];
  } else {
    $assoc = false;
  }
?>

Performing Authentication

Authentication is browser based; the user enters their URL into your site, and you then redirect to their OpenID server with a sprinkle of magic sauce in the get parameters. Here's how you create the sauce:

<?php
  // $identifier is the URL they gave to you
  // $server is the server you discovered
  // $delegate is the identity you discovered
  // $returnURL is your auth endpoint to receive the results
  $x = parse_url($server);
  $params = array();
  if (isset($x['query'])) {
    foreach (explode('&', $x['query']) as $param) {
      list($k, $v) = explode('=', $param, 2);
      $params[urldecode($k)] = urldecode($v);
    }
  }
  // get assoc details from cache, or associate now.
  $assoc = $this->associate($server);
  $params['openid.mode'] = 'checkid_immediate';
  $params['openid.identity'] = $delegate;
  $params['openid.return_to'] = $returnURL;
  $params['openid.trust_root'] = YOUR_TRUST_ROOT_URL;
  $params['openid.sreg.required'] = 'nickname,email';
  if ($assoc !== false) {
    $params['openid.assoc_handle'] = $assoc['handle'];
  }
  $x['query'] = http_build_query($params);
  // you can now assemble $x into a URL and redirect the user there
?>

Once the user has authenticated against their ID server, they'll be redirected back to your $returnURL:

<?php
    $assoc = $this->associate($args['srv']);
    $token_contents = '';
    /* note well: the name in the token_contents hash is the
     * name without any prefix.
     * This nuance can keep you occupied for hours. */
    foreach (explode(',', $_GET['openid_signed']) as $name) {
      $token_contents .= "$name:" . $_GET["openid_" . str_replace('.', '_', $name)] . "\\n";
    }
    $x = hash_hmac('sha1', $token_contents, $assoc['secret'], true);
    $hash = base64_encode($x);
    if ($hash === $_GET['openid_sig']) {
      // Authenticated
      return true;
    }
    /* not valid for whatever reason; we need to do a dumb mode check */
    $params = array();
    $signed = explode(',', $_GET['openid_signed']);
    $signed = array_merge($signed, array('assoc_handle', 'sig', 'signed', 'invalidate_handle'));
    foreach ($signed as $name) {
      $k = "openid_" . str_replace('.', '_', $name);
      if (array_key_exists($k, $_GET)) {
        $params["openid.$name"] = $_GET[$k];
      }
    }
    $server = $args['srv'];
    /* broken spec.  You need to set openid.mode to
     * check_authentication to get it to do the auth checks.
     * But, it needs openid.mode to be id_res for the signature to work.
     */
    $params['openid.mode'] = 'check_authentication';
    $res = perform_openid_rpc($server, $params);
    if (isset($res['invalidate_handle'])) {
      if ($res['invalidate_handle'] === $assoc['handle']) {
        /* remove association */
        $this->associate($server, true);
      }
    }
    return $res['is_valid'] === 'true';
?>

Didn't he also mention TypeKey?

Yeah, here's how to validate the signature you get when your user is redirected back from TypeKey:

<?php
    $keydata = array();
    $regkeys = cache::httpGet('http://www.typekey.com/extras/regkeys.txt', 24*60*60);
    if ($regkeys === false) {
       die("urgh");
    }
    foreach (explode(' ', $regkeys) as $pair) {
      list($k, $v) = explode('=', trim($pair));
      $keydata[$k] = $v;
    }
    $sig = str_replace(' ', '+', $_GET['sig']);
    $email = $_GET['email'];
    $name = $_GET['name'];
    $nick = $_GET['nick'];
    $ts = $_GET['ts'];
    $msg = "$email::$name::$nick::$ts::" . TYPEKEY_TOKEN;
    if (time() - $ts > 300) {
      die("possible replay");
    }
    list($r_sig, $s_sig) = explode(':', $sig, 2);
    $r_sig = base64_decode($r_sig);
    $s_sig = base64_decode($s_sig);
    $valid = openssl_dsa_verify(sha1($msg, true),
                                openssl_bignum_from_bin($r_sig),
                                openssl_bignum_from_bin($s_sig),
                                $keydata['p'], $keydata['q'],
                                $keydata['g'], $keydata['pub_key']);
?>