UnlockOS Developers
← 記事一覧に戻る
🔐

ロックSDKの堅牢化:秘密情報・フェイルクローズ・トークン

2026年8月17日2026年8月23日
9
165 commits
深度 8/10
securityreliabilitytypescripterror-handlingtesting

ロックSDKの堅牢化:シークレット、フェイルクローズなエラー、改ざん耐性のある認可

はじめに

スマートロックのSDKは、普通のWeb依存ライブラリとは異なります。公開CDN経由でブラウザに配信され、午前3時には到達不能かもしれないハードウェアゲートウェイと通信し、「誰がそのドアを開けたのか?」 という問いに答える監査証跡を生成します。これらの性質はいずれも、「良いエンジニアリング」の意味を変えてしまいます。

本記事では、私たちのSDKとコントロールプレーンで最近実施した堅牢化サイクルを、一般化できるパターンとして抽出します。クライアントバンドルからシークレットを排除すること、ログから資格情報をリダクトすること、クライアント側が保持する認可状態を信用しないこと、トークンリフレッシュをsingle-flight化すること、フェイルクローズなエラーセマンティクスを選ぶこと、ワイヤ上の単位を型レベルで強制すること、そしてマイグレーションがEdge Functionと競合しないようにデプロイ順序を制御すること——といった内容です。


1. シークレットはクライアントバンドルに触れた時点で終わり

今回修正した中で最もコストの高い不具合は、APIキーが公開キャッシュされたCDNバンドルに到達していたことでした。このキーはサーバーサイドのヘルパー専用だったのですが、共有モジュールがブラウザ向けのエントリポイントからimportされてしまい、バンドラーが嬉々として process.env.* をインライン展開したのです。

教訓は2つあります。

  1. ローテーションは最初の一歩であって、修正ではありません。 一度CDNの成果物に含まれたキーは永久に公開されたものと見なすべきです。キャッシュやミラー、スクレイパーはforce-pushに従ってくれません。
  2. 予防はCIの仕事です。 import連鎖が5階層も深くなれば、コードレビューでは捕捉できません。

出力された成果物をスキャンするビルド時ガードは安価で、この種の問題をまとめて捕捉できます。

#!/usr/bin/env bash
set -euo pipefail
# Fail the build if anything that looks like a provider key reaches dist/
PATTERNS='AIza[0-9A-Za-z_-]{35}|sk-[A-Za-z0-9]{20,}|SUPABASE_SERVICE_ROLE_KEY'
if grep -rEl "$PATTERNS" dist/ 2>/dev/null; then
  echo "::error::secret-like string found in build output" >&2
  exit 1
fi
echo "bundle scan clean"

ソース側では、境界を暗黙ではなく明示的にします。特権的な環境変数を読める唯一の場所となるモジュールを1つ用意し、それがブラウザで決して実行されないことをランタイムアサーションで保証します。

// server-only.ts — imported exclusively from server entry points
if (typeof window !== 'undefined') {
  throw new Error('server-only module was bundled into a client entry point');
}
export function requireServerSecret(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`missing required secret: ${name}`);
  return value;
}

このthrowによって、静かな情報漏洩がE2E実行中の即座かつ騒がしい失敗に変わります。これこそ望ましいトレードオフです。

系:モデル/プロバイダ識別子は一箇所に固定する

関連するクリーンアップとして、すべての呼び出し箇所を1つのプロバイダモデル定数に統一し、廃止された識別子を削除しました。散在した文字列リテラルは単なる保守性の問題ではありません。プロバイダがIDを廃止したとき、呼び出し箇所の半分が曖昧な4xxエラーで失敗し、残り半分は動き続けるため、非決定的に見えるインシデントが発生します。エクスポートされた定数1つと、廃止IDに対するCIのgrepがあれば、この曖昧さは消えます。

export const AI_MODEL = 'provider-3.7-flash' as const;
export type AiModel = typeof AI_MODEL;

2. 上流のエラーボディはログに残す——ただし資格情報はリダクトする

デバッグ容易性と機密性は相反します。上流の呼び出しが失敗したときはレスポンスボディをログに残したいものですが、認証エラーで失敗した場合、そのボディ(および一緒に出力するリクエスト)にはトークンが含まれていることがよくあります。

答えは「ログを減らす」ことではなく、ロギング境界での構造化リダクションです。

const SENSITIVE_KEYS = /^(authorization|x-api-key|cookie|set-cookie|apikey|token|refresh_token)$/i;
const SENSITIVE_VALUE = /(AIza[0-9A-Za-z_-]{20,}|Bearer\s+[A-Za-z0-9._-]{10,})/g;

export function redact(input: unknown, depth = 0): unknown {
  if (depth > 6) return '[depth-limit]';
  if (typeof input === 'string') return input.replace(SENSITIVE_VALUE, '[REDACTED]');
  if (Array.isArray(input)) return input.map((v) => redact(v, depth + 1));
  if (input && typeof input === 'object') {
    return Object.fromEntries(
      Object.entries(input as Record<string, unknown>).map(([k, v]) =>
        SENSITIVE_KEYS.test(k) ? [k, '[REDACTED]'] : [k, redact(v, depth + 1)],
      ),
    );
  }
  return input;
}

これを単一の logUpstreamFailure ヘルパーに組み込み、素の console.error(response) はlintルールで禁止します。同じヘルパーが、信頼境界を越えて呼び出し元に何を返すかを決める場所にもなります。

export function toClientError(err: unknown, requestId: string) {
  logUpstreamFailure({ requestId, detail: redact(err) }); // full detail, internal only
  return { code: 'upstream_unavailable', requestId }; // opaque, external
}

生のデータベースエラーがAPI利用者に漏れているのを発見した後、まさにこの方法を適用しました。内部のテーブル名やカラム名は偵察材料になりますし、サポートエンジニアが実際に必要とするのは相関のための requestId だけです。


3. クライアントが保持するロール状態はヒントであって、決定ではない

ある管理コンソールでは、リロード後にUIを復元するためにオペレーターが選択したロールを localStorage に永続化していました。localStorage は定義上、攻撃者が書き換え可能です。修正は改ざんガードでした。復元時に、保存された値を現在のセッションでサーバーが実際に付与したロールと突き合わせ、想定外の値は破棄しかつ記録します。

type RoleLevel = 'viewer' | 'staff' | 'manager' | 'owner';
const ORDER: RoleLevel[] = ['viewer', 'staff', 'manager', 'owner'];

export function restoreSelectedRole(
  stored: string | null,
  grantedFromSession: RoleLevel[],
): RoleLevel {
  const fallback = grantedFromSession[0] ?? 'viewer';
  if (!stored) return fallback;
  const candidate = ORDER.find((r) => r === stored);
  if (!candidate || !grantedFromSession.includes(candidate)) {
    auditLog('role_restore_rejected', { stored, granted: grantedFromSession });
    return fallback;
  }
  return candidate;
}

コードそのものより重要な性質が2つあります。

  • サーバーはいずれにせよ再チェックします。 このガードはUXを改善しシグナルを生み出しますが、強制ポイントではありません。特権的なエンドポイントはすべて、セッションのクレームと行レベルポリシーに対して認可を行います。
  • 拒否は監査イベントです。 改ざんされたロール値は、管理画面で収集できる中でも最もシグナル性の高い指標の1つです。

4. テナント単位にスコープしたsingle-flightなトークンリフレッシュ

私たちのロックプロバイダは短命なトークンを発行します。バースト負荷時——チェックアウトの一斉処理や朝の到着ラッシュ——には、数十の並行リクエストがそれぞれトークンの期限切れを検知し、それぞれがリフレッシュを発火します。するとプロバイダがレート制限をかけたり以前のトークンを無効化したりし、SDKには障害のように見える401のカスケードが観測されます。

修正は、施設(facility)をキーとしたsingle-flight(リクエスト合流)キャッシュです。あるテナントのリフレッシュ嵐が別のテナントを止めないようにします。

type Token = { value: string; expiresAt: number };
const inflight = new Map<string, Promise<Token>>();
const cache = new Map<string, Token>();
const SKEW_MS = 60_000;

export async function getToken(facilityId: string): Promise<Token> {
  const cached = cache.get(facilityId);
  if (cached && cached.expiresAt - SKEW_MS > Date.now()) return cached;
  const existing = inflight.get(facilityId);
  if (existing) return existing;
  const p = fetchToken(facilityId)
    .then((token) => {
      cache.set(facilityId, token);
      return token;
    })
    .finally(() => {
      inflight.delete(facilityId); // always clear, success or failure
    });
  inflight.set(facilityId, p);
  return p;
}

本番で効いてくる細部は次のとおりです。

  • finally で必ずMapをクリアすること。さもないと1回のリフレッシュ失敗がそのキーを永久に汚染します。
  • スキュー(ずれ)ウィンドウを使って期限切れの前にリフレッシュすること。ランタイムとプロバイダ間のクロックドリフトは現実に存在します。
  • 私たちが遭遇した関連する障害モードに、夜間のトークンの冷え込みがあります。トラフィックがないとキャッシュされたトークンが期限切れになり、その日の最初のリクエストがリフレッシュ+リトライのコストを払うことになります。スケジュールされたウォームアップ、あるいは「401のときにちょうど1回だけリフレッシュする」という明示的なリトライポリシーで、コールドスタート時の401を解消できます。

5. フェイルクローズ:「到達不能」をビジネス上の状態に偽装させない

今回のサイクルで最も危険なバグは情報漏洩ではありませんでした。ロックプロバイダが応答しなくなったとき、ある統合処理がトランスポート障害を*「満室(capacity full)」*を意味するドメイン状態にマッピングしていたのです。オペレーターはもっともらしいビジネスメッセージを見て、それに基づいて判断していました。トランスポート障害がUI上の嘘になっていたわけです。

2つの結果を別の型として表現し、コンパイラが混同を許さないようにします。

type ProviderResult<T> =
  | { kind: 'ok'; data: T }
  | { kind: 'domain'; reason: 'capacity_full' | 'not_permitted' }
  | { kind: 'unavailable'; retryable: true; cause: string };

async function queryCapacity(id: string): Promise<ProviderResult<Capacity>> {
  let res: Response;
  try {
    res = await fetchWithTimeout(`/capacity/${id}`, { timeoutMs: 5_000 });
  } catch (cause) {
    return { kind: 'unavailable', retryable: true, cause: String(cause) };
  }
  if (res.status >= 500 || res.status === 429) {
    return { kind: 'unavailable', retryable: true, cause: `http_${res.status}` };
  }
  if (res.status === 409) return { kind: 'domain', reason: 'capacity_full' };
  if (!res.ok) return { kind: 'unavailable', retryable: true, cause: `http_${res.status}` };
  return { kind: 'ok', data: await res.json() };
}

現在SDK全体に適用しているルールは次のとおりです。答えが得られていない状態を、確定的な答えとして描画してはならない。 タイムアウト、5xx、パース失敗はすべて unavailable にマッピングされ、UIは「ロックサービスに到達できませんでした」+リトライとして表示します。自信たっぷりのビジネス上の事実としては表示しません。


6. 単位とビット幅を型システムに載せる

ファームウェア周辺の2つの欠陥は、同じ根本原因を共有していました。意味がコメントにしか存在しない整数です。

  • あるディスパッチャが、を規定したワイヤ契約に対してミリ秒のepochを送信し、署名済みコマンドの検証が失敗しました。
  • コマンド署名のepochが32ビットに切り詰められており、2038年にもまだドアに設置されているであろう機器に2038年問題のオーバーフローが潜んでいました。

ブランド型は単位を契約の一部にし、それを生成する唯一の手段を1つのアサーション関数に限定します。

declare const brand: unique symbol;
export type EpochSeconds = number & { readonly [brand]: 'EpochSeconds' };

export function toEpochSeconds(ms: number): EpochSeconds {
  if (!Number.isFinite(ms)) throw new TypeError('non-finite timestamp');
  const seconds = Math.floor(ms / 1000);
  if (seconds < 0 || seconds > 4_102_444_800) throw new RangeError('epoch out of range');
  return seconds as EpochSeconds;
}

export function signCommand(payload: Payload, issuedAt: EpochSeconds): string {
  // `number` no longer type-checks here — the unit mistake is a compile error
  return hmac(`${payload.deviceId}.${payload.action}.${issuedAt}`);
}

ファームウェア側での同等の対応は、int64_t へ拡幅し、time_t が32ビットのツールチェーンではビルドを拒否することです。プレースホルダのままの .env 値をビルド時に拒否するのも同じカテゴリに属します。誤った設定を、現場で検出可能にするのではなくビルド不能にするのです。


7. デプロイ順序も信頼性の一部

2つのCI変更が、中途半端にデプロイされた状態という問題群をまるごと防ぎました。

  1. サーバーレス関数のデプロイをマイグレーション完了でゲートする。 関数が先に配信されると、新しいコードがまだ存在しないカラムをクエリし、その間のすべてのリクエストが500になります。
  2. マイグレーションジョブをキャンセル不可にする。 migrate を適用途中で殺すようなキャンセルされたワークフローは、旧コードも新コードも理解できない中間状態のスキーマを残しかねません。
jobs:
  migrate:
    runs-on: ubuntu-latest
    concurrency:
      group: db-migrate-${{ github.ref }}
      cancel-in-progress: false   # never interrupt an in-flight apply
    steps:
      - run: ./scripts/migrate.sh --transactional
  deploy-functions:
    needs: migrate                # ordering is explicit, not hopeful
    runs-on: ubuntu-latest
    steps:
      - run: ./scripts/deploy-functions.sh

これをexpand/contract型マイグレーション(NULL許容カラムの追加 → バックフィル → デュアルライト → 読み取り切り替え → 削除)と組み合わせれば、どんな順序でも、たとえ失敗しても、新旧両バージョンのコードが動作できる状態が保たれます。


8. 監査証跡には正規の信頼できる情報源が必要

アンロックイベントはロックシステムのコンプライアンス成果物であり、帰属の誤りは重大です。私たちは、共有ロックでのアンロックイベントが、たまたま時間的に重なった予約に帰属させられていることを発見しました。つまり、あるゲストの入室が別のゲストの履歴に現れ得たのです。

修正は、推測をやめて正規のマッピング(部屋が宣言しているロックID)と結合すること、そしてすべてのクエリを施設でスコープすることでした。

select e.id, e.occurred_at, e.credential_id, r.id as reservation_id
from unlock_events e
join rooms rm
  on e.lock_id = any(rm.lock_ids)
 and rm.facility_id = e.facility_id
left join reservations r
  on r.room_id = rm.id
 and r.facility_id = e.facility_id
 and e.occurred_at between r.access_start_at and r.access_end_at
 and r.credential_id = e.credential_id   -- identity, not just time overlap
where e.facility_id = $1
order by e.occurred_at desc;

原則は次のとおりです。時間的な偶然ではなく、identity(同一性)に基づいて帰属させること。テナントのスコープを外側の where だけでなく、すべてのjoin条件に持たせること。そして失敗したアンロック試行も同じビューに出すこと——拒否された試行は、成功した試行よりも興味深いことがしばしばあります。


9. 一度も復元したことのないバックアップは仮説にすぎない

私たちは1つではなく2つのワークフローを追加しました。スケジュールされた論理バックアップ、使い捨てデータベースをプロビジョニングして最新の成果物をリストアし、不変条件(行数、マイグレーションのhead、いくつかの重要な制約)を検証するスケジュール済みのリストアテストです。リストアジョブは、2つ目の資格情報を重複して持つのではなく、既存のパスワードシークレットから接続URLを導出します——シークレットが減れば、ローテーション漏れも減ります。

restore-test:
  schedule: { cron: '0 3 * * *' }
  steps:
    - run: ./scripts/restore.sh --into "$EPHEMERAL_DB_URL" --artifact latest
    - run: psql "$EPHEMERAL_DB_URL" -v ON_ERROR_STOP=1 -f ./scripts/restore-assertions.sql

まとめ

セキュリティクリティカルなSDKで信頼を得るパターンは、その大半が曖昧さの除去に関するものです。

  • 越境時にthrowする境界からはシークレットは漏れません。そしてCIは意図ではなく成果物をスキャンします。
  • リダクションを強制された単一のチョークポイントに置けば、ログは有用かつ安全に保たれます。外部向けエラーはスタックトレースではなく相関IDを持ちます。
  • クライアントに永続化された認可状態はサーバーの付与内容と照合し、拒否は監査されます。
  • トークンリフレッシュはテナント単位でsingle-flight化され、負荷スパイクが認証障害を製造できないようにします。
  • トランスポート障害とビジネス状態は別の型であり、「不明」が「満室」として描画されることはありません。
  • 単位と整数のビット幅はコメントではなく型システムに存在します。
  • デプロイ順序はパイプラインで宣言され、マイグレーションは適用途中でキャンセルされません。
  • 監査の帰属は、テナントでスコープした上で、identityに基づいて正規マッピングと結合されます。
  • バックアップは自動リストアによって、スケジュールに従って検証されます。

どれも珍しいものではありません。しかしこれらが、一見 動いているシステムと、障害モードを事前に説明できるシステムとの違いを生むのです。

主要な発見

1
セキュリティ

バンドル出力をシークレットスキャンの対象にする

深いimport連鎖を通じてAPIキーが公開CDNバンドルに到達しました。ローテーションは第一歩にすぎず、持続的な修正はブラウザでthrowするserver-onlyモジュールと、出力成果物からキー形式の文字列を探すCIスキャンです。

2
セキュリティ

強制された単一のロギング・チョークポイントでリダクトする

上流のエラーボディはデバッグに不可欠である一方、そのままログに出すのは危険です。ヘッダー/フィールド名とトークンパターンに基づく再帰的リダクタにより、内部ログは完全なまま、外部レスポンスは不透明なコードとリクエストIDだけを返せます。

3
認可

クライアントに永続化されたロール状態はヒントであり決定ではない

localStorageから復元したロールはサーバーが付与したロールと突き合わせ、不一致なら最小権限にフォールバックして監査イベントを発行します。サーバーは特権呼び出しのたびに独立して認可を行います。

4
信頼性

テナント単位でスコープしたsingle-flightなトークンリフレッシュ

並行した期限切れ検知がリフレッシュ嵐と401のカスケードを引き起こしました。施設単位のin-flightマップでリフレッシュを合流させ、finallyでエントリをクリアし、クロックスキュー分だけ前倒しでリフレッシュすることで自作自演の障害を解消できます。

5
エラーハンドリング

「到達不能」をビジネス状態として描画しない

プロバイダのタイムアウトが「満室」を意味するドメイン理由にマッピングされ、UIが自信満々に誤った表示をしていました。ok / domain / unavailable を分離した判別可能なユニオンにより、この混同はコンパイルエラーになります。

6
型安全性

単位と整数のビット幅を型に符号化する

ミリ秒と秒のワイヤ上の不一致がコマンド署名検証を壊し、32ビットのepochが2038年オーバーフローを隠していました。検証を行う単一のコンストラクタを持つブランド型と、ファームウェア側のint64により、いずれのバグもビルド時に移せます。

7
運用

順序制御とリストアテストは正しさの一部

関数デプロイはマイグレーション完了でゲートされ、マイグレーションジョブはキャンセル不可であるため、中途半端なスキーマ状態を防げます。バックアップはスケジュールされたリストア+検証ジョブと対にし、復旧を仮定ではなく検証済みにします。

8
監査

アンロックイベントは時間の重なりではなくidentityで帰属させる

共有ロックにより、アンロックイベントが時間的に重なった予約に割り当てられていました。正規の部屋↔ロックのマッピングを資格情報のidentityと結合し、すべてのjoinを施設でスコープすることで、信頼できる監査証跡が回復します。