背景
自分のツールサイトに、Wi-Fi に接続するための QR コードを作るページを置いています。
https://hashitosystem.com/tools/wifiqr/
スマホのカメラで読むと、パスワードを打たずにそのまま接続できる、あの QR です。
コードを読み返していたら、SSID とパスワードを \ ; , : " の5文字だけエスケープしていました。
; は区切りなので分かるのですが、: まで逃がす理由が自分でも説明できなかったので、読み取り側のコードまで追いかけてみました(QR の仕様は毎回うろ覚えです…)。
Wi-Fi の QR の中身
前提です。
Wi-Fi 接続用の QR は、画像の中に下記のような1行の文字列を入れているだけだ。
WIFI:T:WPA;S:mynetwork;P:mypass;;
T: が認証方式(WPA / WEP / nopass)、S: が SSID(Wi-Fi のネットワーク名)、P: がパスワード、H: がネットワークを隠しているかどうか。
各項目は ; で終わり、最後に ; をもう1つ付けて閉じます。
この形式は、オープンソースの QR 読み取りライブラリ ZXing(ゼブラクロッシング)が wiki にまとめたものが事実上の基準になっています。
https://github.com/zxing/zxing/wiki/Barcode-Contents
そこには、\ ; , " : の5文字はバックスラッシュでエスケープする、と書かれている。
項目の順番は問わない、とも書かれています。
ツールでは、SSID とパスワードを下記の関数に通してから組み立てています。
ssid と pass は入力欄の値です。
function esc(s){ return String(s).replace(/([\\;,:"])/g, '\\$1'); }
'WIFI:T:WPA;S:' + esc(ssid) + ';P:' + esc(pass) + ';H:false;;';
読み取り側はどう項目を探しているか
ZXing の読み取り処理(ResultParser と WifiResultParser)を読むと、項目はおおむね下記の手順で取り出していました。
-
WIFI:を外した残りの文字列から、P:のような接頭辞をindexOfで探す - そこから先の
;を探し、直前のバックスラッシュが奇数個なら「エスケープされた ;」として飛ばす - 見つかった
;までを値とし、最後にバックスラッシュを外す
ポイントは1です。
接頭辞を探すとき、項目の区切りを意識せず、文字列の先頭から素直に探している。
なので SSID の中に P: という並びがあると、そこをパスワードの始まりだと思ってしまいます。
手元で再現する
同じ考え方で書いた最小の読み取り関数で、エスケープの有無を比べました。
build がツール側の組み立て、read が読み取り側です。
Node.js 24 で動かしました。
function esc(s) { return String(s).replace(/([\\;,:"])/g, '\\$1'); }
function build(ssid, pass, escape) {
const e = escape ? esc : (x => x);
return 'WIFI:T:WPA;S:' + e(ssid) + ';P:' + e(pass) + ';H:false;;';
}
function backslashesBefore(s, pos) {
let n = 0; for (let i = pos - 1; i >= 0 && s[i] === '\\'; i--) n++; return n;
}
function field(raw, prefix) {
const at = raw.indexOf(prefix); // 接頭辞を文字列全体から探す
if (at < 0) return null;
const start = at + prefix.length;
let i = start;
while ((i = raw.indexOf(';', i)) >= 0) {
if (backslashesBefore(raw, i) % 2 === 1) { i++; continue; } // \; は値の一部
return raw.substring(start, i).replace(/\\(.)/g, '$1');
}
return null;
}
function read(qr) {
const raw = qr.slice('WIFI:'.length);
return { S: field(raw, 'S:'), P: field(raw, 'P:') };
}
for (const [ssid, pass] of [['AP:2F', 's3cret'], ['Home', 'cafe;latte'], ['Home', 'pa\\ss']]) {
for (const escape of [false, true]) {
const qr = build(ssid, pass, escape);
console.log(escape ? 'esc ' : 'raw ', qr, '=>', JSON.stringify(read(qr)));
}
}
結果は下記です。
raw WIFI:T:WPA;S:AP:2F;P:s3cret;H:false;; => {"S":"AP:2F","P":"2F"}
esc WIFI:T:WPA;S:AP\:2F;P:s3cret;H:false;; => {"S":"AP:2F","P":"s3cret"}
raw WIFI:T:WPA;S:Home;P:cafe;latte;H:false;; => {"S":"Home","P":"cafe"}
esc WIFI:T:WPA;S:Home;P:cafe\;latte;H:false;; => {"S":"Home","P":"cafe;latte"}
raw WIFI:T:WPA;S:Home;P:pa\ss;H:false;; => {"S":"Home","P":"pass"}
esc WIFI:T:WPA;S:Home;P:pa\\ss;H:false;; => {"S":"Home","P":"pa\\ss"}
1行目が今回の本題です。
SSID が AP:2F(2階のアクセスポイント、くらいの気持ちで付けそうな名前)だと、エスケープしない場合はパスワードが 2F と読まれました。
S:AP:2F の中の P:2F を、P: 項目の始まりだと見たわけです。
エスケープすると AP\:2F になり、P: という並び自体が文字列から消えるので、正しく s3cret が取れる。
2行目は ; を含むパスワードで、エスケープしないと cafe で切れました。
3行目はバックスラッシュを含むパスワードで、エスケープしないと読み取り側が \s を「s をエスケープしたもの」と解釈して pass になってしまう。
どれも QR は問題なく読めて、接続だけが失敗するタイプの壊れ方なので、気づきにくいです。
なぜ5文字全部を逃がすのか
: を逃がすのは、上のとおり接頭辞の誤検出を防ぐためでした。
, は、同じ書式の元になった MECARD(連絡先 QR の書式)で区切りに使われている文字なので、同じ規則に合わせて逃がしているようです。
「この文字は今の読み取り器では問題にならないから省く」と個別に判断すると、読み取りアプリごとの実装差に振り回されるので、wiki に書かれた5文字をそのまま全部逃がすことにしています。
なお、16進数に見える SSID は "ABCD" のように二重引用符で囲む、という注記も wiki にありますが、ZXing の WifiResultParser 自体は引用符を特別扱いしていませんでした。
ここは読み取りアプリ次第っぽいので、ツールでは対応していません。
Wi-Fi の QR を自分で組み立てるときは、SSID とパスワードの両方に \ ; , : " の5文字のエスケープを必ず通してください。
特に : は、SSID に P: や S: を含む名前があると、エスケープしないだけで別のパスワードとして読まれます。
本記事はAI補助で執筆した、個人開発の紹介記事です。