AWSのMFAをjsで生成する「totp.js」を読む

公開:2021/02/03
更新:2021/02/04
9 min読了の目安(約8200字TECH技術記事

はじめに

外部ライブラリを使わないjavascriptのTOTP実装 として totp.js というのを作成したのだが、力作すぎて解説記事を書かないと自分でもわからなくなるので、ここに供養する。
この記事はいわゆる「組織を強くする技術の伝え方」でいうところの「裏図面」である。(ちなみにこの本、エンジニア、プロジェクトマネージャ、プロダクトマネージャ、スクラムマスター諸氏にとって必読書だと思っている。けっこう図書館にも置いてあるし、1時間くらいで読めちゃうはず)

https://gist.github.com/matobaa/fd519dbcfff2c30cb56597194d1a4541
totp.js
//javascript:
(secret=>{
var b32=s=>[0,8,16,24,32,40,48,56]
 .map(i=>[0,1,2,3,4,5,6,7]
  .map(j=>s.charCodeAt(i+j)).map(c=>c<65?c-24:c-65))
  .map(a=>[(a[0]<<3)+(a[1]>>2),
   (a[1]<<6)+(a[2]<<1)+(a[3]>>4),
   (a[3]<<4)+(a[4]>>1),
   (a[4]<<7)+(a[5]<<2)+(a[6]>>3),
   (a[6]<<5)+(a[7]>>0),
  ]).flat(),
 trunc=dv=>dv.getUint32(dv.getInt8(19)&0x0f)&0x7fffffff,
 c=Math.floor(Date.now()/1000/30);
 crypto.subtle.importKey('raw',new Int8Array(b32(secret)),{name:'HMAC',hash:{name:'SHA-1'}},true,['sign'])
 .then(k=>crypto.subtle.sign('HMAC',k,new Int8Array([0,0,0,0,c>>24,c>>16,c>>8,c])))
 .then(h=>document.querySelector('#mfacode').value=('0'+trunc(new DataView(h))).slice(-6))
})('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX')

AWSなど、多要素認証で使うQRコードには、以下のような情報が埋め込まれている。
otpauth://TYPE/LABEL?secret=XXX&Issuer=YYY
このsecretを取り出して上記実装の最後に指定して呼び出すことで、#mfacode に TOTPの6桁が入力される。ブックマークレットにしておくことで、「スマートフォンのGoogle Authenticator を起動し、Issuerを選択し、6桁の数字を目で見て入力する」なんて手間が不要になる。

実装の解説

以降、実装を解説していく。

ブックマークレットの表明

#L1
//javascript:

ブックマークレットのプレフィクス。javascriptの実装そのものをブックマークに入れて、あるページを表示している状態から、そのブックマークを開く操作でjavascriptを実行できる「ブックマークレット」は、最初を javascript: で始める。
この行自体はコメントアウトしてあるので実装上の意味はないが、この実装がブックマークレットであることを表明している。

即時実行関数式

#L2,17
(secret=>{ /* ... */ })('XXXXX')

javascriptの関数定義文である 仮引数=>{関数定義} の後ろにすぐ(実引数)をつけることで、定義した関数にその場で実引数を渡して実行している。 const func = (secret => {}); func('XXXXX') をインライン展開したかたち。
function (仮引数) {} というスタイルで記述する場合、関数定義の前に(を付けないとパーサは関数定義だと解釈するが、(などを付けることで即時実行であるように解釈させることができる。+!で始める流儀もある。
Immediately Invoked Function Expression; 即時実行関数式

base32デコーダの実装

#L3,11
var b32=s=>[0,8,16,24,32,40,48,56]
 .map(i=>[0,1,2,3,4,5,6,7]
  .map(j=>s.charCodeAt(i+j)).map(c=>c<65?c-24:c-65))
  .map(a=>[(a[0]<<3)+(a[1]>>2),
   (a[1]<<6)+(a[2]<<1)+(a[3]>>4),
   (a[3]<<4)+(a[4]>>1),
   (a[4]<<7)+(a[5]<<2)+(a[6]>>3),
   (a[6]<<5)+(a[7]>>0),
  ]).flat(),

引数として受け取ったbase32文字列を、整数の配列にして返す。base32の詳細な仕様は rfc4648 を参照。簡単に書くと、文字AZを整数値0~25、文字27を整数値26~31に読み替えたあと、 各文字を5ビットで表現したものを連結し、8ビットごとに区切って整数化する。8文字ごとに5バイトが生成される。

base32 変換表

文字 A B C D E F G H I J K L M N O P
値(10進) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
値(16進) 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
文字 Q R S T U V W X Y Z 2 3 4 5 6 7
値(10進) 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
値(16進) 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F

変換例:

  1. base32文字列 NQC42JFR ULD4RJP7
  2. 変換表で数値化 0D 10 02 1C 1A 09 05 11 | 14 0B 03 1C 11 09 0F 1F
  3. 5ビットで表現 01101 10000 00010 11100 11010 01001 00101 10001 | 10100 01011 00011 11100 10001 01001 01111 11111
  4. 8ビット毎に区切って 01101100 00000101 11001101 00100100 10110001 | 10100010 11000111 11001000 10100101 11111111
  5. 16進で表現 6C 05 CD 24 B1 A2 C7 C8 A5 FF

実装にもどろう:

#L3,5
s=>
[0,8,16,24,32,40,48,56].map(i=>[0,1,2,3,4,5,6,7].map(j=>s.charCodeAt(i+j))
.map(c=>c<65?c-24:c-65)
)

8文字ごとに「0文字目から7文字目を一文字ずつ取り出して、変換表で変換」している。
[ [0D, 10, 02, 1C, 1A, 09, 05, 11], [14, 0B, 03, 1C, 11, 09, 0F, 1F], ...] という構造の結果が生成される。

#L6,9
a=>[(a[0]<<3)+(a[1]>>2),
   (a[1]<<6)+(a[2]<<1)+(a[3]>>4),
   (a[3]<<4)+(a[4]>>1),
   (a[4]<<7)+(a[5]<<2)+(a[6]>>3),
   (a[6]<<5)+(a[7]>>0),]

[0D, 10, 02, 1C, 1A, 09, 05, 11] という形の長さ8の配列を受け取って、8ビットずつに区切り直した [6C, 405, 1CD, D24, B1] という値を持つ長さ5の配列を作っている。

  • 1つ目の要素が「0D を左に3ビットシフトした値と、10 を右に2ビットシフトした値の和」
  • 2つ目の要素が「10 を左に6ビットシフトした値と、02 を左に1ビットシフトした値と、1C を右に4ビットシフトした値の和」
  • 3つ目の要素が「1C を左に4ビットシフトした値と、1A を右に1ビットシフトした値の和」
  • 4つ目の要素が「1A を左に7ビットシフトした値と、09 を左に2ビットシフトした値と、05 を右に5ビットシフトした値の和」
  • 5つ目の要素が「05 を左に5ビットシフトした値と、11 を右に0ビットシフトした値の和」
#L11
.flat()

[ [6C, 405, 1CD, D24, B1], [A2, 2C7, 1C8, 8A5, 1FF], ...] という配列を、
[ 6C, 405, 1CD, D24, B1, A2, 2C7, 1C8, 8A5, 1FF, ...] というフラットな形に変えている。

(一休み)

  • Q: 必要なのは下8bitだけじゃない? 上位にはみ出したビットはどうするの?
  • A: そう。邪魔なんだけども、あとでInt8Arrayで受けるところで上位ビットが無視されるので気にしていない。
  • Q: Int8Arrayは上位ビットを削るなんて書いてないけど Undocumented spec?
  • A: ええっと…… ToInt8で変換するみたいです。項番4で 'Let int8bit be int modulo 2^8' のように上位ビットを削っていました。
  • Q: b32呼び出し側でInt8Array化するルールにするんじゃなくて、b32の中でInt8Array化してあげるべきでは?
  • A: 言われてみれば、ご指摘のとおりです! 設計改善ポイントですね!
  • Q: var じゃなくて const で受けるべきでは?
  • A: 文字数
  • Q: [0,1,2,3,4,5,6,7].map() ってなんか実装ダサくない?
  • A: もっと文字数が少ない実装があるといいのだけれど。substringして一文字ずつ渡せばいいのかなぁ
  • Q: ビット論理和+ じゃなくて | だと思うけど?
  • A: むぎゅ~ (返す言葉がない)
  • Q: ケツカンマ(文字数)
  • A: すいません

TOTPの実装

TOTP (Time-Based One-Time Password) として、RFC6238とRFC4226で定義されているものを実装する。

#L12,18
 trunc=dv=>dv.getUint32(dv.getInt8(19)&0x0f)&0x7fffffff,
 c=Math.floor(Date.now()/1000/30);
 crypto.subtle.importKey('raw',new Int8Array(b32(secret)),{name:'HMAC',hash:{name:'SHA-1'}},true,['sign'])
 .then(k=>crypto.subtle.sign('HMAC',k,new Int8Array([0,0,0,0,c>>24,c>>16,c>>8,c])))
 .then(h=>document.querySelector('#mfacode').value=('0'+trunc(new DataView(h))).slice(-6))

rfc6238

TOTP = HOTP(K, T)
T = (Current Unix time - T0) / X
T0の既定値は0。Xの既定値は30。

rfc4226

HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
Kは共有鍵
Cはカウンタ (変化する値)

Step 1: HS = HMAC-SHA-1(K,C) ... HSは20バイトのハッシュ値
Step 2-1: HSの最後4ビットを取り出す → オフセット 0~15
Step 2-2: HSのオフセットバイト目から4バイトを取り出す
Step 2-3: その下位31ビットを取り出す
Step 3: それを10進表記して下6文字を取り出す

実装を順番に見ていこう:

#L12
var trunc=dv=>dv.getUint32(dv.getInt8(19)&0x0f)&0x7fffffff;
  • _offset_ = dv.getInt8(19)&0x0f ... Step 2-1: HSの最後4ビットを取り出す
  • dv.getUint32( _offset_ ) ... Step 2-2: HSのオフセットバイト目から4バイト(32ビット)を取り出す
  • &0x7fffffff ... Step 2-3: その下位31ビットを取り出す
#L13
var c=Math.floor(Date.now()/1000/30);

TOTPで使うカウンタを算出している。Date.now()のミリ秒を秒に換算して、30で割る。

#L14,15
crypto.subtle.importKey('raw',new Int8Array(b32(secret)),
        {name:'HMAC',hash:{name:'SHA-1'}},true,['sign'])
.then(k=>crypto.subtle.sign('HMAC',k,new Int8Array([0,0,0,0,c>>24,c>>16,c>>8,c])))

HMAC-SHA-1 は 組み込みライブラリWeb Crypto API を使うことで計算できる。まず SubtleCrypto.importKey() を使って HMAC-SHA-1 用のCryptoKeyを作り、それを SubtleCrypto.sign() に食わせることで計算結果が得られる。
どちらの関数もPromiseを返すので、結果を受け取るために.then()を使う必要がある。

#L16
.then(h=>document.querySelector('#mfacode').value=('0'+trunc(new DataView(h))).slice(-6))

計算結果をthen経由で受け取って、後ろ6文字を取り出し、mfacodeというIDを持つDOM要素に格納している。まれに桁数が不足することがあるので、頭に文字0を付加して補っている。

刈り取り

  • Q: Promiseの結果はawaitで受け取るほうがすっきり書けるのでは?
  • A: await の後ろに空白を置かないといけないのだけれど、ブックマークレットでは空白を使いたくない (いちいち%20って書き換えないといけない) から then にしました。
  • Q: そういう理由なら new Int8Array() は Int8Array.from() にすれば?
  • A: はっ。そうかも!!
  • Q: '0'一文字だけ補ってるけど大丈夫? 足りなくない?
  • A: はっ。そうかも!!

おわりに

ここまで読んでくれてありがとう。
質問や指摘があったら気軽にコメントに書いてね。感想も歓迎。