splats logo

ワンタイムQRの詳細仕様

ワンタイムQRとは

ワンタイムQRは、定期的に変化するQRコードによる認証システムです。従来の静的のQRコードとは異なり、複製による使いまわしのリスクを軽減することができます。

言葉の定義

ワンタイムQRに関連する言葉の定義を行います。

言葉 説明

フォーマットバージョン

ワンタイムQRの文字列形式のバージョンです。将来的なフォーマット変更に備えて管理されており、現在はv=1が有効です。

TOTPシード

TOTPアルゴリズム(RFC 6238準拠)でワンタイムパスワードを生成するための秘密鍵です。この値を基に現在時刻から6桁のワンタイムパスワードが計算されます。

タイムステップ

ワンタイムQRが切り替わる時間間隔(秒数)です。デフォルトは300秒で、この間隔で異なるTOTPコードが生成されます。設定可能範囲は30秒〜86400秒(24時間)です。

許容ずれ

時刻のずれや認証処理の遅れに対応するための設定です。現在の時間窓に加えて、過去・未来それぞれ何回分の時間窓のワンタイムQRを有効とするかを指定します。デフォルトは前後0回(合計1つの時間窓で認証可能)です。

タイムステップと許容ずれ

言葉の定義 にあるように、タイムステップとはTOTPコードが生成される時間間隔です。すなわち、ワンタイムQRが切り替わる時間を意味します。

「TOTPコードが切り替わる直前にQRコードデータを生成し、メンバーがワンタイムQRをかざしたときにはTOTPコードが切り替わってしまっていたので、認証に失敗してしまった。」という状況を防ぐために、許容ずれを設定することができます。

下図の例では、タイムステップを30秒、許容ずれを2回と設定した場合を表しています。
このとき、現在のステップにおけるTOTPコード「570740」は、前後2回分を含めた2分30秒間は有効となります。

timestep and skew

フォーマット

バージョン1

ワンタイムQRのQRコードデータは以下の形式で構成されます。

SL-OTQR?v=1&data={認証データ}&totp={TOTPコード}
one time qr data
内容(*は必須) 形式 説明

スキーマ識別子*

SL-OTQR?

ワンタイムQRのQRコードデータは SL-OTQR? から始まる文字列です。この識別子がない場合やフォーマットに従っていない場合は静的QRのQRコードデータとして認証されます。

フォーマットバージョン

v=1

省略時も v=1 として扱われます。

認証データ部*

data={認証データ}

メンバーに登録した認証データを指定します。このデータを基に認証対象を特定します。ワンタイムQRを使用する場合、認証データに '&' は使用できません。

TOTPコード部*

totp={TOTPコード}

現在時刻から生成された6桁のワンタイムパスワードを指定します。タイムステップごとに変化します。詳細は _TOTPコードの計算方法 を参照してください。

ワンタイムQRは 半角557文字または全角235文字以下 の範囲内で生成してください。認証データや追加情報のサイズを大きくすると認証に失敗する可能性があります。

静的QRの設定がされているメンバーでは、設定したQRコードデータをQRコード画像に変換して認証しますが、
文字列 SL-OTQR?data={設定したQRコードデータ} をQRコード画像に変換して認証することも可能です。

TOTPコードの計算方法

TOTPコード部で指定するワンタイムパスワードは、 RFC6238 で公開されている「時間ベースのワンタイムパスワードアルゴリズム」を使用して計算します。

RFC4226 で定義されているように、ハッシュ関数にはSHA-1を使用し、出力は6桁となるように切り捨てを行います。

TOTPコードの生成に必要なデータは現在時刻、TOTPシード、タイムステップの3つです。メンバーごとに設定されたTOTPシードとタイムステップを関数 generate_totp (generateTOTP) に引数として与えることで、TOTPコードを取得することができます。

TOTPコード生成の実装例(Python)
import hmac
import hashlib
import time
import base64


def generate_totp(totp_seed_b32: str, timestep: int) -> str:
    """
    TOTP(Time-based One-Time Password)を生成
    RFC 6238準拠の実装
    """
    # Base32デコードして秘密鍵を取得
    totp_seed = base64.b32decode(totp_seed_b32)

    # 現在時刻からカウンター値を計算
    current_step = int(time.time()) // timestep
    counter = current_step.to_bytes(8, byteorder='big')

    # HMAC-SHA1でハッシュを計算
    hmac_result = hmac.new(totp_seed, counter, hashlib.sha1).digest()

    # Dynamic Truncation: 最後のバイトの下位4ビットをオフセットとして使用
    offset = hmac_result[19] & 0xf

    # オフセット位置から4バイトを取得し31ビット整数を構成
    otp = (
        (hmac_result[offset] & 0x7f) << 24
        | (hmac_result[offset + 1] & 0xff) << 16
        | (hmac_result[offset + 2] & 0xff) << 8
        | (hmac_result[offset + 3] & 0xff)
    ) % 1000000

    # 6桁の文字列として返す
    return str(otp).zfill(6)


if __name__ == '__main__':
    # Base32エンコードされたシード(メンバー取得APIで得られるtotp_seed)
    totp_seed_b32 = 'JH4MV7R7FV55TVB43FKSE5GNV2JRXXAL'
    # タイムステップ(メンバー取得APIで得られるtimestep)
    timestep = 30
    totp_code = generate_totp(totp_seed_b32, timestep)
    print(totp_code)
TOTPコード生成の実装例(JavaScript)
import { createHmac } from 'crypto';

/**
 * Base32文字列をUint8Arrayにデコードする
 */
function base32Decode(input) {
    const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
    const normalizedInput = input.toUpperCase().replace(/=/g, "");

    const fiveBitValues = [];
    for (const char of normalizedInput) {
        const alphabetIndex = BASE32_ALPHABET.indexOf(char);
        if (alphabetIndex === -1) {
            throw new Error(`Invalid Base32 character: '${char}'`);
        }
        fiveBitValues.push(alphabetIndex);
    }

    const decodedBytes = [];
    let accumulator = 0;
    let bitsInAccumulator = 0;

    for (const fiveBits of fiveBitValues) {
        accumulator = (accumulator << 5) | fiveBits;
        bitsInAccumulator += 5;

        while (bitsInAccumulator >= 8) {
            const byte = (accumulator >> (bitsInAccumulator - 8)) & 0xFF;
            decodedBytes.push(byte);
            bitsInAccumulator -= 8;
        }
    }

    return new Uint8Array(decodedBytes);
}

/**
 * TOTP(Time-based One-Time Password)を生成
 * RFC 6238準拠の実装
 */
function generateTotp(totpSeedB32, timestep) {
    // Base32デコードして秘密鍵を取得
    const totpSeed = base32Decode(totpSeedB32);

    // 現在時刻からカウンター値を計算
    const currentStep = Math.floor(Date.now() / 1000 / timestep);
    const counterBytes = new Uint8Array(8);
    const view = new DataView(counterBytes.buffer);
    view.setBigUint64(0, BigInt(currentStep), false);  // false = ビッグエンディアン

    // HMAC-SHA1でハッシュを計算
    const hmacResult = new Uint8Array(createHmac('sha1', totpSeed).update(counterBytes).digest());

    // Dynamic Truncation: 最後のバイトの下位4ビットをオフセットとして使用
    const offset = hmacResult[19] & 0xf;

    // オフセット位置から4バイトを取得し31ビット整数を構成
    const otp = (
        (hmacResult[offset] & 0x7f) << 24
        | (hmacResult[offset + 1] & 0xff) << 16
        | (hmacResult[offset + 2] & 0xff) << 8
        | (hmacResult[offset + 3] & 0xff)
    ) % 1000000;

    // 6桁の文字列として返す
    return otp.toString().padStart(6, '0');
}

function main() {
    // Base32エンコードされたシード(メンバー取得APIで得られるtotp_seed)
    const totpSeedB32 = 'JH4MV7R7FV55TVB43FKSE5GNV2JRXXAL';
    // タイムステップ(メンバー取得APIで得られるtimestep)
    const timestep = 30;
    const totpCode = generateTotp(totpSeedB32, timestep);
    console.log(totpCode);
}

main();
TOTPコード生成の実装例(Go)
package main

import (
	"crypto/hmac"
	"crypto/sha1"
	"encoding/base32"
	"encoding/binary"
	"fmt"
	"time"
)

// generateTotp はTOTP(Time-based One-Time Password)を生成
// RFC 6238準拠の実装
func generateTotp(totpSeedB32 string, timestep int) (string, error) {
	// Base32デコードして秘密鍵を取得
	totpSeed, err := base32.StdEncoding.DecodeString(totpSeedB32)
	if err != nil {
		return "", err
	}

	// 現在時刻からカウンター値を計算
	currentStep := time.Now().Unix() / int64(timestep)
	counter := make([]byte, 8)
	binary.BigEndian.PutUint64(counter, uint64(currentStep))

	// HMAC-SHA1でハッシュを計算
	h := hmac.New(sha1.New, totpSeed)
	h.Write(counter)
	hmacResult := h.Sum(nil)

	// Dynamic Truncation: 最後のバイトの下位4ビットをオフセットとして使用
	offset := hmacResult[19] & 0xf

	// オフセット位置から4バイトを取得し31ビット整数を構成
	otp := (int(hmacResult[offset]&0x7f) << 24 |
		int(hmacResult[offset+1]&0xff) << 16 |
		int(hmacResult[offset+2]&0xff) << 8 |
		int(hmacResult[offset+3]&0xff)) % 1000000

	// 6桁の文字列として返す
	return fmt.Sprintf("%06d", otp), nil
}

func main() {
	// Base32エンコードされたシード(メンバー取得APIで得られるtotp_seed)
	totpSeedB32 := "JH4MV7R7FV55TVB43FKSE5GNV2JRXXAL"
	// タイムステップ(メンバー取得APIで得られるtimestep)
	timestep := 30
	totpCode, err := generateTotp(totpSeedB32, timestep)
	if err != nil {
		panic(err)
	}
	fmt.Println(totpCode)
}

TOTPは広く一般的に知られるアルゴリズムで各種ライブラリも整っています。連携の際に既存のライブラリを使用して実装しても問題ありません。実装後は、ワンタイムQR取得APIのレスポンス結果と一致するか必ず検証してください。時刻同期のずれや実装の違いにより結果が異なる場合があります。
また、WinAuthやGoogle Authenticatorなどのツールでも、TOTPシードを登録することでTOTPコードを生成することができます。(タイムステップは各ツールの設定と合わせる必要があります。)これらのツールを使用して自身で実装したプログラムの計算結果が間違っていないかを確認することも可能です。

残り秒数の計算方法

ワンタイムQRが切り替わるまでの秒数、すなわち、現在のタイムステップにおける残り秒数はタイムステップから算出することができます。

残り秒数の計算の実装例(Python)
import time


def get_totp_expiration_in_seconds(timestep: int):
    """
    TOTPの現在のタイムステップの残り秒数を計算する
    """
    current_unix_time = int(time.time())

    # 現在の時間窓内での経過秒数を計算
    elapsed_seconds_in_current_window = current_unix_time % timestep

    # 残り秒数 = タイムステップ - 経過秒数
    expiration_in_seconds = timestep - elapsed_seconds_in_current_window

    return expiration_in_seconds


if __name__ == '__main__':
    # タイムステップ(メンバー取得APIで得られるtimestep)
    timestep = 30

    # 現在のタイムステップの残り秒数を出力
    print(get_totp_expiration_in_seconds(timestep))
残り秒数の計算の実装例(JavaScript)
/**
 * TOTPの現在のタイムステップの残り秒数を計算する
 */
function getTotpExpirationInSeconds(timestep) {
    const currentUnixTime = Math.floor(Date.now() / 1000);

    // 現在の時間窓内での経過秒数を計算
    const elapsedSecondsInCurrentWindow = currentUnixTime % timestep;

    // 残り秒数 = タイムステップ - 経過秒数
    const expirationInSeconds = timestep - elapsedSecondsInCurrentWindow;

    return expirationInSeconds;
}

// 使用例
function main() {
    // タイムステップ(メンバー取得APIで得られるtimestep)
    const timestep = 30;

    // 現在のタイムステップの残り秒数を出力
    console.log(getTotpExpirationInSeconds(timestep));
}

// 実行
main();
残り秒数の計算の実装例(Go)
package main

import (
	"fmt"
	"time"
)

// TOTPの現在のタイムステップの残り秒数を計算する
func getTotpExpirationInSeconds(timestep int64) int64 {
	currentUnixTime := time.Now().Unix()

	// 現在の時間窓内での経過秒数を計算
	elapsedSecondsInCurrentWindow := currentUnixTime % timestep

	// 残り秒数 = タイムステップ - 経過秒数
	expirationInSeconds := timestep - elapsedSecondsInCurrentWindow

	return expirationInSeconds
}

func main() {
	// タイムステップ(メンバー取得APIで得られるtimestep)
	timestep := int64(30)

	// 現在のタイムステップの残り秒数を出力
	fmt.Println(getTotpExpirationInSeconds(timestep))
}

残り秒数の取りうる値の範囲は、0秒~設定されたタイムステップの秒数となります。
ワンタイムQR取得APIで得られる残り秒数や、自身で計算した結果が、残り0秒や残り1秒であったということもあり得ます。