私は、ブラウザー拡張「Popout Authenticator」の開発者です。Authenticator のMITライセンスのコードを引き継ぎ、OTPの生成やインポート、バックアップの処理を見直しています。公開されたコードと、問題を報告・修正してきた開発者の皆さんに感謝しています。
メンテナーのmymindstormさんは、2026年2月16日に、修正が集まっていることは把握しているものの、マージと配布に十分な時間を割けない状況だと説明しています。PR #1451のコメント
この記事では、フォークの過程で扱った次の3点を、実際のコードに沿って紹介します。
- QRコードから読み込んだアルゴリズム名の解釈
- Base32のパディングとバイト列への変換
- 平文バックアップからのアカウントの欠落と、その検出
原版の参照先はコミット 9d9660bb73700b3e725800edc22836662d94afac に固定しています。イシューとPRの状態は2026年10月16日時点です。イシューに書かれた症状と、ソースから確認できる処理は区別して説明します。
1. SHA-256・SHA-512の指定をSHA-1に変えてしまう
症状
#1593 では、SHA-512を使うQRコードを読み込むとコードが一致せず、同じアカウントを手動で追加してSHA-512を選ぶと一致する、と報告されています。SHA-256などの扱いについても問題が提起されています。
原版のコードで起きること
QRコードの読み取り処理は、algorithm パラメーターを文字列として取り出します。たとえば algorithm=SHA256 なら、値は "SHA256" です。src/background.ts:176–177
一方、取り込み先では次のように数値へ変換しています。
algorithm: rawAlgorithm
? (parseInt(rawAlgorithm) as OTPAlgorithm)
: OTPAlgorithm.SHA1,
出典:src/models/storage.ts:490–492
parseInt("SHA256") の結果は NaN です。その後、無効な値をSHA-1へ置き換える分岐があります。
if (!OTPAlgorithm[entryData.algorithm]) {
entryData.algorithm = OTPAlgorithm.SHA1;
}
出典:src/models/storage.ts:507–509
この経路では、取り込み自体は進んでも、指定されたアルゴリズムが変わってしまいます。
フォークでは名前と数値を明示的に解釈する
Popout Authenticatorでは、文字列の名前と、原版のバックアップに使われる数値の列挙値を別々の対応表で扱います。
const BY_NAME: Record<string, Algorithm> = {
SHA1: 'SHA-1',
'SHA-1': 'SHA-1',
SHA256: 'SHA-256',
'SHA-256': 'SHA-256',
SHA512: 'SHA-512',
'SHA-512': 'SHA-512',
};
algorithmFrom() の文字列側の処理は次のとおりです。
const key = value.replace(/\s+/g, '').toUpperCase();
const found = BY_NAME[key];
if (found) return found;
if (UNSUPPORTED_NAMES.has(key)) throw new UnsupportedAlgorithmError(key);
throw new Error(`unknown algorithm: ${value}`);
対応する名前がなければエラーにします。数値の場合も、対応表にある値だけを受け付けます。
また、otpauth URIでは、アルゴリズムの指定がない場合と、指定された値を解釈できない場合を分けています。
const algorithmParam = params.get('algorithm');
const algorithm = algorithmParam === null ? 'SHA-1' : algorithmFrom(algorithmParam);
原版のバックアップを読む処理でも、指定された値には同じ関数を使います。src/core/import/original.ts:67–71
ここで分けたかったのは、省略時の既定値と、未知の入力に対する処理です。認証コードを生成する設定では、読めない値を別の有効な値に置き換えると、取り込みの失敗を見つけにくくなります。
なお、原版には同じ列挙値の解釈を修正する PR #1451 がすでにあり、確認時点ではオープンです。
2. Base32はパディングを整理してからバイト列にする
症状
#1546 では、Base32の秘密鍵をQRコードから取り込むと、他の認証アプリとコードが一致しないケースが報告されています。報告には、パディングなしと末尾に = を付けた入力の両方が含まれています。
原版では入力経路とデコード処理を合わせて見る必要がある
原版のQRパーサーは、各パラメーターを item.split("=") で分割し、秘密鍵には parameter[1] を使います。このため、値の末尾にそのまま書かれた = は秘密鍵に残りません。src/background.ts:153–156
一方、Base32デコーダーは、= を見つけるとゼロのビットを追加します。
if (base32.charAt(i) === "=") {
bits += "00000";
padding++;
出典:src/models/key-utilities.ts:46–48
その後、4ビット単位で16進文字列を作り、パディングの個数に応じて末尾を削ります。パディング数の分岐は 0・1・3・4・6 で、それ以外は例外になります。src/models/key-utilities.ts:55–80
したがって、この問題を「= が付いていると失敗する」とだけ説明するのは不正確です。QR側でパディングが失われる処理と、パディング数を使って16進文字列を調整する処理の両方を見る必要があります。
フォークでは末尾のパディングを外して本文を検査する
Popout Authenticatorでは、URIのクエリーを URLSearchParams で読みます。src/core/uri.ts:27
Base32デコーダーでは、空白・ハイフンを取り除き、大文字にそろえ、末尾の = を外してから本文を検査します。
const text = input
.replace(/[\s-]+/g, '')
.toUpperCase()
.replace(/=+$/, '');
if (text.length === 0) throw new Error('empty base32 input');
if (!BASE32_BODY.test(text)) throw new Error('invalid base32 character');
const rem = text.length % 8;
if (rem === 1 || rem === 3 || rem === 6) throw new Error('invalid base32 length');
BASE32_BODY は A–Z と 2–7 だけを受け付けるため、途中に残った = は拒否されます。末尾の = の個数は厳密に検査せず、取り除いた本文の長さで判定する方針です。
変換では、各文字の5ビットをため、8ビットそろった分だけ出力します。
for (const ch of text) {
buffer = (buffer << 5) | ALPHABET.indexOf(ch);
bits += 5;
if (bits >= 8) {
bits -= 8;
out[pos++] = (buffer >> bits) & 0xff;
buffer &= (1 << bits) - 1;
}
}
この実装では、パディングから秘密鍵のバイトを作りません。なお、余ったビットがゼロかどうかを検査する実装ではないため、厳密な正規形の検証とは区別しています。
原版にも、QRから取り込むBase32を正規化する PR #1547 があり、確認時点ではオープンです。こちらは不足するパディングを補う方法で、フォークの実装とは方法が異なります。
3. バックアップは生成した内容を読み直して照合する
症状
#1568 には、画面上とパスワード保護付きJSONには11件あるのに、平文バックアップには6件しか入らない、という報告があります。
原版には項目を出力から外す分岐がある
getExport() は、暗号化状態のままの項目や秘密鍵がない項目をスキップします。
if (entry.code === CodeState.Encrypted || !entry.secret) {
continue;
}
出典:src/models/storage.ts:363–365
別の backupGetExport() には、EncOTPStorage の項目で後続処理をスキップする分岐があります。この continue 自体は、元のアカウントを削除する処理ではありません。src/models/storage.ts:389–392
さらに、otpauthテキストの出力側は、種類を文字列と比較します。
if (otpStorage.type === "totp" || otpStorage.type === "hex") {
type = "totp";
} else if (otpStorage.type === "hotp" || otpStorage.type === "hhex") {
type = "hotp";
} else {
continue;
}
出典:src/components/Popup/BackupPage.vue:215–221
数値の列挙値が残っていると、この文字列比較には一致しません。
これらはソースで確認できる欠落の経路です。ただし、#1568の報告者のデータを調べたわけではないため、その11件のうち5件がどの分岐で外れたかまでは断定できません。
フォークでは出力を再解析して、元のアカウントと比べる
Popout Authenticatorのエクスポート画面では、ロックが有効なら再認証し、保管データを復号してアカウントを読み込みます。src/ui/manage/backup/ExportPanel.tsx:70–71、src/store/vault.ts:120–124
生成したファイル内容には verifyExport() を呼び出します。
const verify = await verifyExport(accounts, kind, text, pw);
setBuilt({ skipped, sig, verify, file: { text, ext } });
出典:src/ui/manage/backup/ExportPanel.tsx:75–76
照合側では、削除済みではなく、その出力形式で表現できるアカウントを期待値にします。出力を読み直し、比較用のキーごとに件数を数えます。
const expected = accounts.filter((a) => a.deletedAt === undefined && representable(kind, a));
const available = new Map<string, number>();
for (const item of await reread(kind, output, password)) {
const key = signature(item);
available.set(key, (available.get(key) ?? 0) + 1);
}
出典:src/core/export/verify.ts:70–75
比較する内容は、種類・秘密鍵・アルゴリズム・桁数と、TOTPの周期またはHOTPのカウンターです。総件数が同じでも、これらが異なれば一致とは数えません。src/core/export/verify.ts:19–21、src/core/account.ts:123–125
画面には期待件数と一致件数を表示し、不足があればアカウント名を表示します。出力形式の制約で含められない項目も別に表示します。src/ui/manage/backup/ExportPanel.tsx:230–295
ここには限界もあります。この照合は、ダウンロード前に生成した内容をメモリー上で読み直すものです。ディスクに保存されたファイルを開き直す検証ではなく、不一致時にダウンロードを禁止する実装でもありません。ダウンロード操作時にバックアップ記録を更新するのは、期待件数と一致件数が同じで、形式による除外もない場合です。src/ui/manage/backup/ExportPanel.tsx:120–132
原版には、暗号化項目の平文出力と、種類・アルゴリズムの正規化を扱う PR #1578 がすでにあり、確認時点ではオープンです。このPRが直接参照しているイシューは、同種のバックアップ欠落を扱う #1445 です。
現在の対応範囲
Popout Authenticatorには、Google Driveなどへの直接のクラウドバックアップ機能はありません。保存・移行にはファイルバックアップと、対応ブラウザーの同期機能を使います。Safari版はブラウザー同期に対応していません。
対象はデスクトップのブラウザー拡張で、Safari版はmacOS向けです。2026年10月18日時点で、Chrome・Edge・Firefox・Whaleで公開しており、Safari版(macOS)は審査中です。対応範囲と配布状況
コードはMITライセンスで公開しています。
この記事は、開発者がAI(Claude・Codex)の支援を受けて執筆し、日本語に翻訳しました。掲載したコードと技術的な説明はリポジトリで、イシュー・PRの状態は各公開ページで確認しています。