サーバ側でパスワードを受け取らずにはてブを編集する方法

はてなブックマークAtomAPIを使ってサービスを提供したいけど,サーバ側にはてなのパスワードを平文で保存しておかなければならないのがちょっと…という方!
サーバ側で平文のパスワードを受け取らなくても,平文のパスワードを保存しておかなくても大丈夫な方法があるんです!!

そのためには,はてなブックマークAtomAPIのリプレイアタックに対する脆弱性を利用します.
クライアントにはてなIDとパスワードを入力させ,それを元にX-WSSEヘッダの内容を生成し,サーバにその内容だけを送ればOKです.
サーバにはこのX-WSSEの内容を保存しておけば以後IDもパスワードも入力が不要になります.
※個人的にこの方法は推奨しません(というより,はてなAtomAPIを使ったサービスを提供すること自体オススメしません)

サンプルプログラム

クライアント側 (hatebu.html)

※はてなIDとパスワードは変更してください

<html>
<head>
<script type="text/javascript" src="libwsse.js"></script>
<script type="text/javascript">
// cf. http://thinkit.co.jp/article/1112/1
function make_wsse(username, password) {
    var created = date('Y-m-d\\TH:i:s\\Z'),
        nonce = pack('H*', sha1(md5(time()))),
        digest = base64_encode(pack('H*', sha1(nonce + created + password))),
        wsse = 'UsernameToken Username="' + username + '", PasswordDigest="' + digest
             + '", Nonce="' + base64_encode(nonce) + '", Created="' + created + '"';
    
    return wsse;
}

function edit_bookmark(wsse, /*array*/ url, /*array*/ cmt) {
    var xhr, query = '?wsse=' + wsse + '&url[]=' + url.join('&url[]=') + '&cmt[]=' + cmt.join('&cmt[]=');
    
    try {
        xhr = new ActiveXObject('Microsoft.XMLHTTP');
    } catch(e) {
        xhr = new XMLHttpRequest();
    }

    xhr.onreadystatechange = function() {
        if (xhr.readyState == 4) {
            if (xhr.status == 200) {
                alert(xhr.responseText);
            } else {
                alert('Error! ' + xhr.status);
            }
        }
    }
    
    xhr.open('GET', 'hatebu.php' + query, true);
    xhr.send(null);
}

var username = 'はてなID',
    password = 'パスワード',
    url1     = 'http://favmemo.net/',
    cmt1     = 'Twitterのfav管理サービスで残念な方',
    url2     = 'http://favolog.org/',
    cmt2     = 'Twitterのfav管理サービスで素晴らしい方';

edit_bookmark(make_wsse(username, password), [url1, url2], [cmt1, cmt2]);
</script>
</head>
<body>
</body>
</html>

libwsse.js は php.js から wsse を生成するために必要な関数 (base64_encode, date, md5, sha1, pack, time) を抽出して一部改変したものです.1
ダウンロードはこちらから

サーバ側 (hatebu.php)

<?php
// cf. http://thinkit.co.jp/article/1112/1
require_once 'HTTP/Request.php';

// $_GET['wsse'] は何度も再利用可能なのでサーバに保存しておくといい
for ($i = 0; $i < count($_GET['url']); $i++) {
    edit_bookmark($_GET['wsse'], $_GET['url'][$i], $_GET['cmt'][$i]);
}

function edit_bookmark($wsse, $url, $cmt) {
    $api_url = 'http://b.hatena.ne.jp/atom/post';
    $link = '<link rel="related" type="text/html" href="'.$url.'" />';
    $postdata = '<entry xmlns="http://purl.org/atom/ns#"><title>dummy</title>' . $link
        . '<summary type="text/plain">'.$cmt.'</summary></entry>';
    
    $req = new HTTP_Request();
    $req->addHeader('Accept','application/x.atom+xml, application/xml, text/xml, */*');
    $req->addHeader('Authorization', 'WSSE profile="UsernameToken"');
    $req->addHeader('X-WSSE',$wsse);
    $req->addHeader('Content-Type', 'application/x.atom+xml');
    $req->setMethod(HTTP_REQUEST_METHOD_POST);
    $req->setURL($api_url);
    $req->addRawPostData($postdata);
    
    if (PEAR::isError($req->sendRequest())) {
        exit('通信エラーが発生しました');
    }
    $code = $req->getResponseCode();
    if ($code == 201) {
        $res = (array) simplexml_load_string($req->getResponseBody());
        $edit_id = substr($res['link'][2]->attributes()->href, 10);
        echo "Edit ID is $edit_id\n";
        echo "ブックマークの追加に成功しました\n";
    } else {
        echo "エラーが発生しました:コード = ${code}\n";
    }
}
?>

これで hatebu.html にアクセスすると2つのページがブックマークに追加されます.

おまけ

暗号の強度については詳しくないので time() をMD5ハッシュ化してさらにSHA-1ハッシュ化してそれをさらに16進文字列にして……
とすることでどれだけパスワードが推測されにくくなるかはわかりませんが,次のように wsse を生成してもはてブに追加できます.
(暗号の強度というよりも乱数の良さと言うべきですかね)

function make_wsse(username, password) {
    var nonce1 = sha1(time()),
        nonce2 = sha1(time() + 1),
        digest = base64_encode(pack('H*', sha1(nonce1 + nonce2 + password))),
        wsse = 'UsernameToken Username="' + username + '", PasswordDigest="' + digest
             + '", Nonce="' + nonce1 + '", Created="' + nonce2 + '"';
    
    return wsse;
}

ポイントは Created がもはや送信日時じゃないことですかね.
とにかく Nonce と Created と はてブ のサーバに保存されているパスワードから算出した PasswordDigest が送られてきたものと一致すればいいってことです.
まぁ,はてなフォトライフAtomAPIの説明にWSSE認証が

パスワードはSHA1アルゴリズムによって暗号化されたダイジェストとして送信されるため、HTTP基本認証などに比べてセキュアな認証

と説明されていることから,リプレイアタックは全く考慮しないことにしてるんでしょうね.

実は脆弱性を先に見つけてこれを考えたんじゃなくて,WSSE認証についてあまり理解していない時にこっちを考えて,
「あれ?これができるとしたら,リプレイアタックが可能じゃない?」
と思って脆弱性が判明したわけなんですがね…

  1. そのままだとbase64エンコードする際に16進文字列をISO-8859-1からUTF-8に変換するのでUTF-8で無効な16進文字列が与えられるとバグります