SNSやクラウドを使いたくない時に。長文APIキーをQRコードで安全に転送する仕組みを作った
はじめに
「この長いAPIキーや設定用の長文テキスト、どうやって別の端末に送ろう……」
開発や運用をしていると、こういった場面に直面することがあります。SlackやLINEなどのSNS・チャットツールを使えば簡単ですが、サードパーティのサーバーに履歴が残ってしまうためセキュリティの観点で避けたい。かといって、わざわざテキストファイルにしてクラウドストレージにアップロードし、権限を設定して共有リンクを発行するのはあまりにも面倒です。
この記事では、そういった「ネットワーク越しに送りたくない、でも手入力は不可能なテキスト」を、画面とスマートフォンのカメラ(QRコード)だけを使って安全かつ手軽に転送する仕組みを作ったので紹介します。
今回の要件と制約
今回は以下の条件でテキストを転送することを目標とします。
- SNS・チャットツール: 使用不可(履歴が残る・情報漏洩などのセキュリティ懸念)
- クラウドストレージ / 共有ドライブ: 使用不可(アップロードや権限管理の手間が大きすぎる)
条件: 長いAPIキーや秘匿性の高いテキストを、外部のネットワークを経由することなく、かつ手入力のミスがない状態で別の環境へ転送したい。
最初のアプローチ: とりあえずQRコードで
最初の発想はシンプルでした。テキストをQRコードに変換して、スマートフォンで読み取る。
// 最初の素朴な実装
const input = fs.readFileSync("qr_target.txt", "utf8");
const chunks: string[] = [];
for (let i = 0; i < input.length; i += 1000) {
chunks.push(input.slice(i, i + 1000));
}
// chunks からQRコードを生成...
失敗した3つの理由
1. iOSのQRスキャナーがURLとして開こうとした
コード中に :// が含まれると、スキャナーがURLと判断してSafariを開こうとしてしまいます。テキストとしてコピーされない。
2. スペースが全部消えた
QRスキャナーが空白を正規化してしまい、function test() { return x; } が functiontest(){returnx;} になりました。コードとして使い物になりません。
3. URLが文字化けした
コード中の https://api.example.com がスキャン後に https%3A%2F%2Fapi.example.com に化けました。
結果: 読み取ったテキストはまったく使えませんでした。

ブレイクスルー: Base64エンコード
気づき: 問題はQRコード自体ではなく、スキャナーがテキストを「解釈しようとする」ことでした。解釈できない形式にすれば、スキャナーは素直にテキストとして返すはずです。
Base64の出力は [A-Za-z0-9+/=] のみで構成されます。:// もスペースも特殊文字も含まれません。スキャナーが「これはURLではない」「これはテキストだ」と判断して、素直に文字列として返してくれます。
結果:
- スペース・タブ: 完全保持
- URL: デコード後に正しく復元される
- あらゆるQRスキャナー(iOS標準、Google Lens等)で動作
Base64エンコードが「セキュリティ目的」ではなく「スキャナーの過剰な解釈を防ぐ不透明化」として機能する、という点が発見でした。
QRコードの容量制限
QRコードの最大データ容量はバージョン(サイズ)と誤り訂正レベルによって異なりますが、実用的な上限は以下の通りです。
| 誤り訂正レベル | 最大バイト数(英数字) |
|---|---|
| L(7%) | 約4,296文字 |
| M(15%) | 約3,391文字 |
| Q(25%) | 約2,420文字 |
| H(30%) | 約1,852文字 |
理論上は4,000文字以上入りますが、スキャンの信頼性を考慮すると500〜600文字程度に抑えるのが実用的です。密度が高すぎるQRコードはカメラのフォーカスや照明条件によって読み取りに失敗します。
フレーミングプロトコルの設計
フォーマットの進化
シンプルすぎた最初のフォーマット:
SCRIPT:part:base64data
問題点: 何枚あるか分からない、順番どおりに読まないといけない、異なるセッションのQRコードを混在させてしまう。
最終フォーマット:
SCRIPT:{sessionId}:{partIndex}:{totalParts}:{base64Chunk}
実際の例 (3分割の場合):
SCRIPT:a3f9k2:0:3:SGVsbG9Xb3JsZA==
SCRIPT:a3f9k2:1:3:YWJjZGVmZ2g=
SCRIPT:a3f9k2:2:3:eHl6MTIz

各フィールドの役割
| フィールド | 説明 |
|---|---|
SCRIPT |
プレフィックス。受信側でQRコードの種類を判別 |
sessionId |
セッション識別子(6文字、タイムスタンプベース)。異なる転送の混在を防ぐ |
partIndex |
パートのインデックス(0始まり)。順不同スキャンに対応 |
totalParts |
総パート数。プログレス表示と完了判定に使用 |
base64Chunk |
Base64エンコードされたデータの断片 |
小さなオーバーヘッド(1枚あたり約20文字)が、システム全体の信頼性を大きく向上させました。
実装
チャンク分割とQRコード生成
import QRCode from "qrcode";
public async genQrcode(): Promise<void> {
const MAX_QR_CHARS = 500;
const input = fs.readFileSync("input/target.txt", "utf8");
// セッションID生成(タイムスタンプベース)
const sessionId = Date.now().toString(36).substring(2, 8);
// 全体をBase64エンコード
const base64Input = Buffer.from(input, "utf8").toString("base64");
// Base64文字列をチャンクに分割
const base64Chunks: string[] = [];
for (let i = 0; i < base64Input.length; i += MAX_QR_CHARS) {
base64Chunks.push(base64Input.slice(i, i + MAX_QR_CHARS));
}
// メタデータ付きチャンクを生成
const totalParts = base64Chunks.length;
const chunks: string[] = [];
for (let i = 0; i < totalParts; i++) {
chunks.push(
`SCRIPT:${sessionId}:${i}:${totalParts}:${base64Chunks[i]}`
);
}
// HTMLファイルに出力
await this.generateHtml(chunks, sessionId, totalParts, input.length);
}
MAX_QR_CHARS = 500 はメタデータ部分(SCRIPT:abc123:0:10: ≒ 約25文字)を含めても600文字以下に収まるように設定しています。
QRコード生成とHTML出力
各チャンクからSVG形式のQRコードを生成し、ナビゲーション付きのHTMLに埋め込みます。
for (const [i, chunk] of chunks.entries()) {
const qrSvg = await QRCode.toString(chunk, {
type: "svg",
errorCorrectionLevel: "M", // 中程度の誤り訂正
margin: 2,
width: 250,
});
// HTMLにカード形式で追加
appendToHtml(`
<div class="qr-card">
<h2>Part ${i} of ${totalParts - 1}</h2>
<div class="qr-code">${qrSvg}</div>
<div class="chunk-info">
Session: ${sessionId} | ${chunk.length} characters
</div>
</div>
`);
}
HTMLナビゲーション
生成されるHTMLは矢印キーで前後のQRコードに切り替えられます。
let currentIndex = 0;
const totalCards = ${totalParts};
function showCard(index) {
const cards = document.querySelectorAll('.qr-card');
cards.forEach((card, i) => {
card.classList.toggle('active', i === index);
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' && currentIndex > 0) {
currentIndex--;
showCard(currentIndex);
}
if (e.key === 'ArrowRight' && currentIndex < totalCards - 1) {
currentIndex++;
showCard(currentIndex);
}
});
ボタンとキーボードの両方で操作できるため、オペレーターの作業効率が上がります。
受信側の再構成
受信側では以下のように再構成します。
// 受信したチャンクを保持
const receivedChunks = new Map<number, string>();
let expectedTotal = 0;
function onQrScanned(data: string) {
// SCRIPT:sessionId:part:total:base64chunk をパース
const parts = data.split(":", 5);
if (parts[0] !== "SCRIPT") return;
const partIndex = parseInt(parts[2]);
const total = parseInt(parts[3]);
const base64Chunk = parts[4];
expectedTotal = total;
receivedChunks.set(partIndex, base64Chunk);
// 全パーツが揃ったら再構成
if (receivedChunks.size === expectedTotal) {
const sorted = Array.from(receivedChunks.entries())
.sort(([a], [b]) => a - b)
.map(([_, chunk]) => chunk)
.join("");
const original = Buffer.from(sorted, "base64").toString("utf8");
console.log("Reconstructed:", original);
}
}
スキャン順序は関係ありません。partIndex でソートして結合するため、どの順番でスキャンしても正しく復元されます。
設計上の判断
チャンクサイズ 500文字: 最初は1000文字/QRコードで実装していましたが、密度が高いQRコードはスキャンに5〜10回かかることがありました。500文字に変更するとQRコードの枚数は増えますが、1枚あたりが確実に1回でスキャンできるため、トータルの所要時間は短くなりました。
誤り訂正レベル M(Medium): レベルH(High)にすればQRコードが汚れても読み取れますが、データ容量が減りQRコードが密になりすぎて逆にスキャンしにくくなります。画面表示なので汚れのリスクは低く、Mで十分です。
| レベル | 訂正能力 | データ容量 | 推奨場面 |
|---|---|---|---|
| L | 7% | 最大 | 画面表示(汚れなし) |
| M | 15% | 中 | 一般的な用途 |
| Q | 25% | 小 | 印刷物 |
| H | 30% | 最小 | 過酷な環境 |
SVG形式: PNG形式と比べてHTMLに直接埋め込めるため、外部ファイルの管理が不要です。
実際の転送実績
- 小ドキュメント (~2KB): 約3枚 / 約10秒
- 中ドキュメント (~10KB): 約15枚 / 約1分
- 大ドキュメント (~50KB): 約75枚 / 約5分(手が疲れる)
- ベストゾーン: 5〜20KB(8〜25枚)が快適
体感速度は「枚数」ではなく「リトライの少なさ」で決まります。
まとめ
| 課題 | 最初のアプローチ | 最終的な解決策 |
|---|---|---|
| スペースが消える | プレーンテキストQR | Base64エンコード |
| URLが化ける | :// を除去 |
Base64エンコード |
| 手動貼り付けが煩雑 | テキストエリアに貼る | カメラスキャン + 自動処理 |
| 検証がない | 目視確認 | セッションID + メタデータ |
| 密なQRがスキャンしにくい | 1000文字/枚 | 500文字/枚 + 誤り訂正M |
Base64は「セキュリティ」だけのためにあるわけではない。 スキャナーやパーサーが過剰に解釈することを防ぐための「不透明化」としても有効です。
メタデータの小さなオーバーヘッドは価値がある。 sessionID:part:total の20文字を追加するだけで、「どのセッションか」「何番目か」「全部で何枚か」が分かり、システム全体の信頼性が上がりました。
制約が強いとき、「その制約の中でどう動くか」を考えることで、意外にシンプルな解決策が見つかることがあります。





