以前、WebAuthnを使ったFIDOサーバを立ててみた で、FIDO認証に対応したサーバを作成しました。
今度は、FIDOデバイスを模擬するエミュレータを作ってみます。
普通に作ると、以下のような構成となります。
FIDO認証対応サーバ
↓ (HTTP)
アプリのHTMLページ
WebAuthn(Javascript)
↓ (BLE、HID、NFC)
FIDOデバイスエミュレータ
それをちょっと一工夫を入れて、最終的には、以下のような構成を目指します。
FIDO認証対応サーバ
↓ (HTTP)
アプリのHTMLページ
WebAuthn(Javascript)
↓ (BLE)
Androidスマホアプリ →(HTTP POST)→ FIDOデバイスエミュレータサーバ
どうでしょうか。スマホさえあれば、FIDOデバイスとしていつでもどこでも使えるようになりませんか?!
今回作成する部分は、FIDOデバイスエミュレータサーバの部分です。
で、で、なのですが、なんと、最後のFIDOデバイスとWebAuthnをつなぐトランスポートレイヤ(BLE、HID、NFCのいずれか)が作れるノウハウが私にはありませんっ!残念ながら私の能力ではここまでです。
サーバのソースコードを以下にアップしておきました。
https://github.com/poruruba/fido2_server
以前に作成したFIDOサーバのソースコードにマージしています。
(2020/3/22 修正)
WebAuthnクライアントから受け入れるパケットをAPDU形式にしました。
(2020/4/5 修正)
x509証明書をv3にしました。それに伴い、証明書生成のためのnpmモジュールを変更しました。
FIDOデバイスエミュレータのサーバのWebAPI
用意するWebAPIは、「FIDO U2F Raw Message Formats」で定義されている3つのコマンドです。このコマンドは、HIDやBLEやNFCでやり取りされる際の共通のコマンドです。
・U2F_REGISTER
・U2F_AUTHENTICATE
・U2F_VERSION
それぞれのWebAPIへの入力には、WebAuthnクライアントであるブラウザから受け取ったFIDO U2F Raw Messageをそのまま渡します。以下のAPDUフォーマットになっているかと思います。
CLS INS P1 P2 Lc1 Lc2 Lc3 Data Le1 Le2
ちょっと補足します。
・u2f_register(challenge, applicatoin)
FIDO認証対応サーバに本FIDOデバイスを登録するためのトークンを生成します。FIDO認証対応サーバは、このトークンを覚えておきます。
challenge: challenge parameter[32バイトBuffer]、ClientDataのSHA-256です。
application: application parameter[32バイトBuffer]、アプリケーションアイデンティティのSHA-256です。単に32バイトの配列として扱います。
・u2f_authenticate(control, challenge, application, key_handle)
FIDO認証対応サーバに登録しておいたトークンを使って、本FIDOデバイスを認証します。
control: 1バイト、クライアントがチェックのためだけに認証するのか、ユーザプレゼンスを含めて認証するのかどうかを指示するものです。
challenge: challenge parameter[32バイトBuffer]、ClientDataのSHA-256です。
application: application parameter[32バイトBuffer]、アプリケーションアイデンティティのSHA-256です。単に32バイトの配列として扱います。
key_handle: 本FIDOデバイスの内部で保持する公開鍵ペアを識別するハンドル。u2f_registerで生成されたトークンの中に含まれています。
・u2f_version()
本FIDOデバイスのFIDOバージョンを返します。固定で"U2F_V2"を返します。
WebAuthnと接続するFIDOデバイスに仕立て上げるときには、WebAuthnから受けたコマンドをこのFIDOデバイスエミュレータサーバにWebAPIで問い合わせ、その応答をWebAuthnに返せばよいわけです。
FIDOデバイスエミュレータサーバを立ち上げる
それでは、サーバを立ち上げましょう。
一番簡単なのは、Gitに上げたソースをCloneすることです。
> git clone https://github.com/poruruba/fido_server
> cd fido_server
> npm install
> node app.js
あとは、以下のURLにPOST呼び出しするだけです。
http://localhost:10080/device/u2f_register
http://localhost:10080/device/u2f_authenticate
http://localhost:10080/device/u2f_version
実装には、以下のnpmを使わせていただきました。
・ecdsa-secp256r1
https://github.com/forevertz/ecdsa-secp256r1
・jsrsasign
https://kjur.github.io/jsrsasign/
u2f_register
以下の処理を行います。
・楕円暗号公開鍵ペアの作成
認証を行うサイトごとに楕円暗号公開鍵ペアを生成します。
・内部管理用のアプリケーションIDの決定
FIDOデバイス内で複数の楕円暗号公開鍵ペアを生成し保持する前提で、それらを識別する番号を決めます。今回は単純に、1からのインクリメント値です。
・KeyHandleの作成
認証を行うサイトが、レスポンスで返すトークンを識別するためのハンドルを生成します。今回は、公開鍵ペアの秘密鍵をFIDOデバイスエミュレータサーバで保持するのがめんどうなので、内部管理用のアプリケーションIDと公開鍵ペアの秘密鍵を返しちゃっています。当然ながら、秘密鍵がバレバレになってしまうので、実際にはこんなことはしないで下さい。
・X.509証明書の作成
おれおれX.509証明書を作成します。(おれおれではだめな気がする。。。)
・署名の生成
生成した楕円暗号公開鍵ペアを使って署名を生成します。
u2f_authenticate
以下の処理を行います。
・内部管理用のアプリケーションIDの抽出
KeyHandleに埋め込んでおいた内部管理用のアプリケーションIDを抽出します。
・楕円暗号公開鍵ペアの復元
KeyHandleに埋め込んでおいた楕円暗号公開鍵ペアの秘密鍵を抽出します。
・署名回数カウンタの決定
署名回数カウンタをインクリメントします。内部管理用アプリケーションIDごとにカウンタを覚えておくべきですが、今回は手を抜いています。
・署名生成
署名を生成します。
ソースコードを示します。
'use strict';
const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');
const Redirect = require(HELPER_BASE + 'redirect');
const rs = require('jsrsasign');
const ECDSA = require('ecdsa-secp256r1')
const crypto = require('crypto');
const curveLength = Math.ceil(256 / 8);
const FIDO_ISSUER = process.env.FIDO_ISSUER || 'FT FIDO 0200';
const FIDO_SUBJECT = process.env.FIDO_SUBJECT || 'FT FIDO P2000000000000';
const FIDO_EXPIRE = Number(process.env.FIDO_EXPIRE) || 365;
var total_counter = Number(process.env.COUNTER_START) || 0;
var total_application_id = Number(process.env.APPLICATION_ID_START) || 1;
// X509証明書の楕円暗号公開鍵ペアの作成
var kp_cert = rs.KEYUTIL.generateKeypair('EC', 'secp256r1');
exports.handler = async (event, context, callback) => {
if( event.path == "/device/u2f_register"){
var body = JSON.parse(event.body);
console.log(body);
var input = Buffer.from(body.input, 'hex');
var result = await u2f_register(input.subarray(7, 7 + 32), input.subarray(7 + 32, 7 + 32 + 32));
return new Response({
result: Buffer.concat([ result, Buffer.from([0x90, 0x00])]).toString('hex')
});
}else
if( event.path == "/device/u2f_authenticate"){
var body = JSON.parse(event.body);
console.log(body);
var input = Buffer.from(body.input, 'hex');
var result = await u2f_authenticate(input[2], input.subarray(7, 7 + 32), input.subarray(7 + 32, 7 + 32 + 32), input.subarray(7 + 32 + 32 + 1, 7 + 32 + 32 + 1 + input[7 + 32 + 32]));
return new Response({
result: Buffer.concat([ result, Buffer.from([0x90, 0x00])]).toString('hex')
});
}else
if( event.path == "/device/u2f_version"){
var result = await u2f_version();
return new Response({
result: Buffer.concat([ result, Buffer.from([0x90, 0x00])]).toString('hex')
});
}
};
async function u2f_register(challenge, application){
// 楕円暗号公開鍵ペアの作成
var kp = rs.KEYUTIL.generateKeypair('EC', 'secp256r1');
var pubkey = Buffer.from(kp.pubKeyObj.pubKeyHex, 'hex');
var privkey = Buffer.from(kp.prvKeyObj.prvKeyHex, 'hex');
var privateKey = new ECDSA({
d: privkey,
x: pubkey.slice(1, 1 + curveLength),
y: pubkey.slice(1 + curveLength)
});
var userPublicKey = pubkey;
// 内部管理用のアプリケーションIDの決定
console.log('application_id='+ total_application_id);
// KeyHandleの作成
var keyHandle = Buffer.concat([Buffer.from([(total_application_id >> 24) & 0xff, (total_application_id >> 16) & 0xff, (total_application_id >> 8) & 0xff, total_application_id & 0xff]), privkey] );
total_application_id++;
var keyLength = Buffer.from([keyHandle.length]);
// X.509証明書の作成
var tbsc = new rs.KJUR.asn1.x509.TBSCertificate();
tbsc.setSerialNumberByParam({'int': 1234});
tbsc.setSignatureAlgByParam({'name': 'SHA256withECDSA'});
tbsc.setIssuerByParam({'str': "/CN=FT FIDO 0200"});
tbsc.setNotBeforeByParam({'str': "190511235959Z"});
tbsc.setNotAfterByParam({'str': "340511235959Z"});
tbsc.setSubjectByParam({'str': "/CN=FT FIDO P2000000000000"});
tbsc.setSubjectPublicKey(kp.pubKeyObj);
/*
//サブジェクトキー識別子
var extSKI = new rs.KJUR.asn1.x509.Extension();
extSKI.oid = '2.5.29.14';
const ski = rs.KJUR.crypto.Util.hashHex(kp_cert.pubKeyObj.pubKeyHex, 'sha1');
const derSKI = new rs.KJUR.asn1.DEROctetString({ hex: ski });
extSKI.getExtnValueHex = () => {return derSKI.getEncodedHex() };
tbsc.appendExtension(extSKI);
*/
// FIDO U2F certificate transports extension
var extSKI2 = new rs.KJUR.asn1.x509.Extension();
extSKI2.oid = '1.3.6.1.4.1.45724.2.1.1';
extSKI2.getExtnValueHex = () => { return "03020640" };
tbsc.appendExtension(extSKI2);
var cert = new rs.KJUR.asn1.x509.Certificate({'tbscertobj': tbsc, 'prvkeyobj': kp_cert.prvKeyObj });
cert.sign();
var attestationCert = Buffer.from(cert.hTLV, 'hex');
// 署名の生成
var input = Buffer.concat([
Buffer.from([0x00]),
application,
challenge,
keyHandle,
userPublicKey
]);
const sign = crypto.createSign('RSA-SHA256');
sign.update(input);
var signature = sign.sign(privateKey.toPEM());
console.log('userPublicKey(' + userPublicKey.length + ')=' + userPublicKey.toString('hex'));
console.log('keyHandle(' + keyHandle.length + ')=' + keyHandle.toString('hex'));
console.log('attestationCert(' + attestationCert.length + ')=' + attestationCert.toString('hex'));
console.log('signature(' + signature.length + ')=' + signature.toString('hex'));
// レスポンスの生成(concat)
return Buffer.concat([
Buffer.from([0x05]),
userPublicKey,
keyLength,
keyHandle,
attestationCert,
signature
]);
}
async function u2f_authenticate(control, challenge, application, keyHandle){
console.log('control=', control);
var userPresence = Buffer.from([0x01]);
// 内部管理用のアプリケーションIDの抽出
var application_id = (keyHandle.readUInt8(0) << 24) | (keyHandle.readUInt8(1) << 16) | (keyHandle.readUInt8(2) << 8) | keyHandle.readUInt8(3);
console.log('application_id=' + application_id);
// 楕円暗号公開鍵ペアの復元
var ecdh = crypto.createECDH('prime256v1');
ecdh.setPrivateKey(keyHandle.slice(4));
var pubkey = ecdh.getPublicKey();
var privkey = ecdh.getPrivateKey();
var privateKey = new ECDSA({
d: privkey,
x: pubkey.slice(1, 1 + curveLength),
y: pubkey.slice(1 + curveLength)
})
// 署名回数カウンタの決定
total_counter++;
console.log('total_counter=' + total_counter);
var counter = Buffer.from([ (total_counter >> 24) & 0xff, (total_counter >> 16) & 0xff, (total_counter >> 8) & 0xff, total_counter & 0xff ])
// 署名生成
var input = Buffer.concat([
application,
userPresence,
counter,
challenge
]);
const sign = crypto.createSign('RSA-SHA256');
sign.update(input);
var signature = sign.sign(privateKey.toPEM());
console.log('sigunature(' + signature.length + ')=' + signature.toString('hex'));
// verify sample code
/*
const verify = crypto.createVerify('RSA-SHA256')
verify.write(input)
verify.end();
var result = verify.verify(
privateKey.asPublic().toPEM(),
signature
);
console.log('verify result=' + result);
*/
// レスポンスの生成(concat)
return Buffer.concat([
userPresence,
counter,
signature
]);
}
async function u2f_version(){
var version = Buffer.from('U2F_V2');
return Promise.resolve(version);
}
以下は、それぞれの環境に合わせて変更してください。
・FIDO_ISSUER:X.509証明書に含める発行者名
・FIDO_SUBJECT:X.509証明書に含めるサブジェクト名
・FIDO_EXPIRE:X.509証明書の有効期間
・COUNTER_START:署名回数カウンタの初期値
・APPLICATION_ID_START:内部管理用アプリケーションIDの初期値
最後に
ここまでできていて、最後の最後のHIDまたはBluetoothペリフェラルがうまく作れていません。悲しい。。。
認定済みFIDOデバイスであることの証明が必要な気がするが、Chromeで実際についないでいないので不明。。。
FIDOデバイスとWebAuthnをつなぐトランスポートレイヤの仕様は以下にあります。
BLE:FIDO Bluetooth® Specification v1.0
HID:FIDO U2F HID Protocol Specification
NFC:FIDO NFC Protocol Specification v1.0
以上