一度つないだスピーカーを、スマホは「以前つないだ機器」と分かります。スピーカー側は名前や ID ではなく、Account Key という鍵とブルームフィルタで相手を見分けています。このページでは、その仕組みを仕様どおりに Node で作り、誤検出率を実測します。

Account Key とは

Account Key は、ユーザーの Google アカウントに結びつく事前共有鍵です。ペアリングが成功したあと、スマホが生成してスピーカーに書き込みます。スピーカーはそれを Account Key の一覧として保存します。書き込めるのは、ペアリング成功後の同じ BLE リンク上だけで、仕様には鍵を破棄するタイマーの手順もあります。

Start a timer to discard K after 10 seconds.

出典: https://developers.google.com/nearby/fast-pair/specifications/service/gatt(取得日: 2026-09-21。Procedure の Step 16)

同じ Google アカウントのスマホは同じ Account Key を持ちます。別のスマホでも「以前つないだ」と扱える理由は、ここにあると考えられます(仕様からの推測で、実機では確かめていません)。

鍵を渡さず「持っていそうか」だけを伝える

ペアリングモードでないとき、スピーカーは Account Key の一覧(1 件以上あるときのみ)から作った Account Key Filter を広告します。鍵そのものを流せば、聞いた人が使えてしまいます。そこで、ブルームフィルタという「入っていそうか」を素早く判定するデータ構造に押し込みます。

仕様のフィルタの作り方は次のとおりです。

  1. 鍵の数を n として、フィルタのバイト数 s を trunc(1.2 × n) + 3 にします
  2. s バイトを 0 で初期化します
  3. 各鍵 K に、ランダムな 2 バイトのソルトを付けて SHA-256 にかけます
  4. 得た 32 バイトを 32 ビット整数 8 個に分け(ビッグエンディアン)、それぞれ M = Xi % (s × 8) を求めて、M % 8 ビット目を立てます

スマホは、自分の鍵で同じ計算をして 8 個のビットがすべて立っていれば「持っていそう」と判断します。仕様は、誤検出率を次のように述べています。

low false-positive probability, on average much less than 0.5%

出典: https://developers.google.com/nearby/fast-pair/specifications/service/provider(取得日: 2026-09-21)

仕様どおりに作って測る

上の手順をそのまま Node で書きました。次の 3 点を測っています。

  • 登録した鍵が必ず「持っていそう」になるか
  • 登録していないランダムな鍵が誤って一致する割合(鍵の数 n ごとに、200 回 × 1000 鍵)
  • 同じ鍵でも、ソルトを変えると広告が変わるか
import { createHash, randomBytes } from "node:crypto";

// Fast Pair 仕様 Provider 節の Account Key Filter を、手順どおりに作る
function buildFilter(accountKeys, salt) {
  const n = accountKeys.length;
  const s = Math.trunc(1.2 * n) + 3; // フィルタのバイト数
  const F = new Uint8Array(s);
  for (const K of accountKeys) {
    const H = createHash("sha256").update(Buffer.concat([K, salt])).digest(); // 32 バイト
    for (let i = 0; i < 8; i++) {
      const Xi = H.readUInt32BE(i * 4); // 32 ビット・ビッグエンディアン
      const M = Xi % (s * 8);
      F[Math.floor(M / 8)] |= 1 << (M % 8);
    }
  }
  return F;
}

// スマホ側:自分の鍵がフィルタに含まれそうか
function mightContain(F, K, salt) {
  const s = F.length;
  const H = createHash("sha256").update(Buffer.concat([K, salt])).digest();
  for (let i = 0; i < 8; i++) {
    const M = H.readUInt32BE(i * 4) % (s * 8);
    if (!(F[Math.floor(M / 8)] & (1 << (M % 8)))) return false;
  }
  return true;
}

const newKey = () => randomBytes(16);
const hex = (b) => Buffer.from(b).toString("hex");

// (1) 登録済みの鍵は必ず一致するか
for (const n of [1, 3, 5, 10]) {
  const keys = Array.from({ length: n }, newKey);
  const salt = randomBytes(2);
  const F = buildFilter(keys, salt);
  console.log(`n=${n} フィルタ ${F.length} バイト 登録済みの一致 ${keys.every((k) => mightContain(F, k, salt))}`);
}

// (2) 登録していない他人の鍵の誤検出率(各 n で 200 回 × 1000 鍵の平均)
const TRIALS = 200, STRANGERS = 1000;
for (const n of [1, 3, 5, 10]) {
  let hits = 0;
  for (let t = 0; t < TRIALS; t++) {
    const keys = Array.from({ length: n }, newKey);
    const salt = randomBytes(2);
    const F = buildFilter(keys, salt);
    for (let i = 0; i < STRANGERS; i++) if (mightContain(F, newKey(), salt)) hits++;
  }
  console.log(`n=${n} 他人の鍵の誤検出 ${hits}/${TRIALS * STRANGERS} = ${((hits / (TRIALS * STRANGERS)) * 100).toFixed(3)}%`);
}

// (3) 同じ鍵でもソルトが変わるとフィルタは変わる
const keys = [newKey(), newKey()];
for (let i = 0; i < 3; i++) console.log(`salt を変えた広告 ${i + 1}:`, hex(buildFilter(keys, randomBytes(2))));

読み取れること

  • 登録した鍵は必ず一致します。ブルームフィルタは「入っていないものを入っていると言う」ことはあっても、「入っているものを見逃す」ことはありません
  • 他人の鍵の誤検出は、鍵が 10 件までなら 0.5% を下回りました。ただし鍵が増えるほど上がります
  • 同じ鍵の一覧でも、ソルトが変わるとフィルタの中身が変わります。広告のたびに、フィルタの見た目が変わります

同じコードで鍵の数だけを増やして再実行すると、次の結果でした。

n = 20 と 50 では、仕様の「much less than 0.5%」に当たる値は出ませんでした。仕様の言う「平均」がどの鍵の数を想定しているのかは、原文に書かれていません。自分の実装が仕様の手順と完全に同じかは、実機の広告との突き合わせをしていないので確かめられていません。手元の実測で言えるのは、鍵が 10 件までなら 0.5% を下回った、ということまでです。

この仕組みが伝えないこと

フィルタからは、鍵そのものは取り出せません。見知らぬ人のスマホは、自分の鍵がフィルタに含まれるかを調べるだけで、持ち主の名前も Google アカウントも分かりません。スピーカーが「以前つないだ誰か」を名前や ID で覚えているのではなく、初回ペアリングで渡した鍵で識別している、ということです。

この仕組みは「ペアリングモードでないとき」の話でした。では、ペアリングモードでないスピーカーに、近くの誰かがペアリングを要求してきたらどうなるのでしょうか。次のページで、仕様の 1 行を見ます。