TOTP
Time based One Time Password
二段階認証でよくあるコロコロ変わる6桁のやつ
SHA-1をハッシュ関数に使い、30秒毎に更新、6桁が一般的
セットアップの際に表示されるQRコードのURLは
otpauth://
から始まり、6桁生成に必要な鍵と幾つかのオプションが含まれている
実装
実装において、以下の記事が非常に参考になった
解説はこちらの記事を読んでほしい
まずTOTP生成のコード全文がこちらである
const
totp=async({
_:{
be4=x=>[x>>>24&255,x>>>16&255,x>>>8&255,x>>>0&255],
b32de=w=>((
d=[...'abcdefghijklmnopqrstuvwxyz234567'].reduce((a,x,i)=>(a[x]=i.toString(2).padStart(5,0),a),{}),
b=w.toLowerCase().replace(/./g,x=>d[x]||'')
)=>new Uint8Array([...Array(Math.ceil(b.length/8))].map((_,i)=>+`0b${b.slice(8*i++,8*i).padEnd(8,0)}`)))()
}={},
date:t=new Date,algorithm:h='SHA-1',digits:l=6,period:d=30,secret:{base32:_k='',raw:k=b32de(_k)}
}={})=>(w=>((w.slice(w=w.pop()&0xf,w+4).reduce((a,x)=>a<<8|x)&0x7fffffff)+'').slice(-l))(
[...new Uint8Array(await crypto.subtle.sign('HMAC',
await crypto.subtle.importKey('raw',k,{name:'HMAC',hash:h},0,['sign']),
new Uint8Array([...be4((t=t.getTime()/1000/d)/2**32),...be4(t)])
))]
);
オブジェクト1つを引数に取り、secret.base32またはsecret rawにStringもしくはArrayBuffer,TypedArray,DataViewを渡す
前述のotpauth://
に含まれる鍵はbase32でエンコードされている
主題のHMAC-SHA-1はSubtleCryptoのsignを用いて生成できる
digestではないので注意
signに使う鍵はgenerateKeyで生成するかimportKeyを用いてインポートする
importKeyは幾つかの鍵形式をサポートしているが今回はbase32にエンコードされたバイト列のためこれをデコードしてrawとして渡す
また、ハッシュ関数SHA-1の指定もここで行う
otpauth-migration://
先述のotpauth://
は1つのurlに1つの鍵しか含むことができない
GoogleAuthenticatorは複数の鍵を1つ、多くても数個のQRコードでエクスポート可能である
このQRコードに含まれる文字列がotpauth-migration://offline?data=...
形式のURLである
?data=
以降はbase64でエンコードされたProtocolBuffer Wire formatである
ProtocolBuffer
googleが定めた同社サービスでよく用いられている効率的なデータ送受信のためのエコシステムである
エンコード及びデコードに用いられる~.proto
定義ファイルとそのパーサや処理系、符号化された状態のWire formatの総称である
Wire formatの解説は以下の記事が非常に参考になった
解説はこちらを読んでほしい
実装
const
depb=w=>[...{[Symbol.iterator]:(
p=0,
v=_=>[...{[Symbol.iterator]:d=>({next:_=>({done:d,value:d||(d=!(w[p]&0x80),w[p++]&0x7f)})})}].reduce((a,x,i)=>x<<(7*i)|a)
)=>({next:_=>w.length<=p?{done:1}:{value:((x=>({i:x>>>3,type:x=x&7,value:[
(x=v())=>Object.assign(x,{s:(x+1)/(x&1?-2:2)|0}),_=>w.slice(p,p+=8),(l=v())=>w.slice(p,p+=l),,,_=>w.slice(p,p+=4)
][x]()}))(v()))}})}];
Uint8Arrayを受け取り、番号と種別とデータを含むオブジェクトの配列を返す
内包するデータがWire formatである場合はもう一度この関数を呼び出す前提の設計である
ループはイテレータを用いて実装した
実装
以下、urlをデコードするコードである
const
migurl=w=>((
td=new TextDecoder(),
b32en=w=>(x=>[...Array(Math.ceil(x.length/5))].map(
(_,i)=>'abcdefghijklmnopqrstuvwxyz234567'[+`0b${x.slice(5*i++,5*i).padEnd(5,0)}`]
).join('').padEnd(Math.ceil(x.length/40)*8,'='))(w.reduce((a,x)=>a+x.toString(2).padStart(8,0),''))
)=>(
w=new URL(w),w.href.match(/^[^?]+/)=='otpauth-migration://offline'&&(w=w.searchParams.get('data'))&&
depb(new Uint8Array([...atob(decodeURIComponent(w))].map(x=>x.charCodeAt()))).reduce((a,x)=>(
x.i==1?a.params.push(
depb(x.value).reduce((a,x)=>(([,
x=>a.secret={raw:x,base32:b32en(x)},
x=>a.name=td.decode(x),
x=>a.issuer=td.decode(x),
x=>a.algorithm=[,'SHA-1','SHA-256','SHA-512','MD5'][x],
x=>a.digits=[,6,8][x],
x=>a.type=[,'HOTP','TOTP'][x],
x=>a.conter=x
][x.i]||(y=>a[x.i]=y))(x.value),a),{})
):a[[,,'version','batch_size','batch_index','batch_id'][x.i]||x.i]=x.value,
a
),{params:[]})
))();
実装にあたって以下の.proto
定義を参考にした
otpauth-migration://
urlを渡すとurlに含まれる情報を含むオブジェクトの配列を返す
動作検証
googleAuthenticatorでhelloworld234567
を鍵として入力して、これをotpauth-migration://
形式のqrコードで出力した
得られたurlを用いてbunjs上で動作検証を行った
console.log(await migurl(
'otpauth-migration://offline?data=CjkKCjkWt1nRWPW%2Bd98SEGhlbGxvd29ybGQyMzQ1NjcgASgBMAJCEzlkZDNjMzE3MzI3MjAwMzcyMTIQAhgBIAA%3D'
).params.reduce(async(a,x)=>(x.key=await totp({...x,date:new Date('2024-11-30 23:29')}),[...await a,x]),[]));
bun run ./totp.mjs
:thumbs_up:
これで追加のライブラリを用いずにクライアントのみでTOTPが生成可能である
コードは以下で公開している