Fail Closed: スマートロックバックエンドのセキュリティパターン
はじめに
ソフトウェアが物理的なドアを開けるかどうかを判断するとき、バグのコストは質的に変わります。描画の乱れは煩わしさにすぎませんが、1時間早く漏れた認証情報は「建物の中にいる見知らぬ他人」を意味します。UnlockOS SDK に取り組んできたここ数週間、数十件のコミットを通じて一貫したパターンが浮かび上がってきました。すなわち、fail closed(閉じる方向に失敗する)、開示は最小限に、すべての書き込みを冪等に、そして結果を仮定せずに検証する、という原則です。
本記事では、それらのパターンを一般化した形で抽出します。掲載するコードは説明用であり内部実装そのものではありませんが、以下のパターンはいずれも実際に修正した欠陥から生まれたものです。
1. Fail Closed:決して「降格した状態」で失敗しない
見落としやすく危険なアンチパターンが 降格されたデフォルト値(demoted default) です。これは、参照に失敗したときにシステムが「一見安全そうな」空の値で代替し、それを永続化してしまうというものです。私たちは、JWT にロールと施設スコープを付与する認証フックでこれに遭遇しました。ロールの読み取りが一時的に失敗すると、フックは保存済みクレームに roles: [] を書き込んでいました。正当なオペレーターを密かに降格させるだけでなく、さらに悪いことに、システムに嘘を学習させていたのです。
ルールはこうです。読み取り失敗は空の結果と同じではありません。
type ClaimSource = { ok: true; roles: string[] } | { ok: false; error: Error };
export async function buildClaims(userId: string, stored: StoredClaims): Promise<StoredClaims> {
const roles = await readRoles(userId);
const facilities = await readFacilities(userId);
if (!roles.ok || !facilities.ok) {
// Do NOT persist. Keep the previously verified claims and surface the failure.
logger.error('claim_read_failed', { userId, roles: roles.ok, facilities: facilities.ok });
return { ...stored, refreshedAt: stored.refreshedAt, degraded: true };
}
return persist({ userId, roles: roles.roles, facilities: facilities.ids, refreshedAt: Date.now() });
}同じ原則は時間ベースのゲートにも当てはまります。あるチェックインフローでは、セルフチェックインを許可するかどうかを判断するために「この入口には現在スタッフが常駐しているか?」を問い合わせていました。この読み取りがエラーになると、元のコードはそれを「常駐していない」とみなしてゲストを入館させていました。修正はデフォルト値を1行反転させるだけでした。
const hours = await readStaffedHours(entranceId);
if (hours.error) {
// Unknown staffing state must block self-service, not permit it.
return deny('staffed_hours_unavailable');
}
if (isWithin(hours.value, now)) return deny('staffed_hours_active');
return allow();チェックリスト: 認可の入力に関わるすべての catch ブロックについて、その分岐が何を 許可する のかを問いましょう。答えが「すべて」なら、そこはドアが開きっぱなしです。
2. クライアントから書き込める認可テーブルは権限昇格である
roles / role_members テーブルに対する行レベルセキュリティポリシーの強度は、その WITH CHECK 句の強度でしかありません。私たちは、サインイン済みの任意のユーザーがロール割り当てテーブルに自分の行を挿入できてしまう(事実上、自分でプラットフォーム管理者権限を付与できる)ポリシーに対して、ホットフィックスをリリースしました。
恒久的な修正は構造的なものです。認可テーブルはクライアントから書き込み可能にしない。 すべての変更は、呼び出し元を再チェックする特権関数を経由させます。
-- Deny all direct writes from the anon/authenticated roles.
revoke insert, update, delete on public.role_members from authenticated, anon;
alter table public.role_members enable row level security;
create policy role_members_read_self on public.role_members
for select to authenticated
using (user_id = auth.uid());
-- Mutations happen only here, under a definer that verifies the grantor.
create or replace function public.grant_role(target_user uuid, role_key text, org uuid)
returns void language plpgsql security definer set search_path = public as $$
begin
if not public.is_org_owner(auth.uid(), org) then
raise exception 'forbidden' using errcode = '42501';
end if;
insert into role_members(user_id, role_key, organization_id, granted_by)
values (target_user, role_key, org, auth.uid())
on conflict (user_id, role_key, organization_id) do nothing;
end;
$$;関連する2件の欠陥が、この教訓をさらに裏付けました。
- 施設作成の操作が、対象組織の所有権を要求せず、どこかの組織に所属していることだけを条件としていました。認可は常にリソースのテナントに対して行い、呼び出し元の「どこかのテナント」で済ませてはいけません。
- テナントスコープの識別子(ハードウェアベンダー由来のロック/ユニット ID)が、ビューに組織の述語が欠けていたためにテナント横断で読めてしまいました。識別子もまたデータです。
3. 最小開示:ブロックリストではなくアローリスト
匿名エンドポイントは、ロックシステムにおいて最も価値の高い攻撃対象です。日付や写真を受け取って「該当の予約」を返す予約検索は、デフォルトのままだとその行の すべて を返してしまいます。ドアの PIN や QR ペイロードまで含めて、です。
私たちは「機微なフィールドを取り除く」方式から「許可されたフィールドだけを射影する」方式へ移行しました。ブロックリストは、カラムが追加されるたびに静かに破綻するからです。
const ANON_RESERVATION_FIELDS = ['id', 'status', 'startAt', 'endAt', 'facilityName'] as const;
type AnonReservation = Pick<Reservation, typeof ANON_RESERVATION_FIELDS[number]>;
function projectAnonymous(row: Reservation): AnonReservation {
return ANON_RESERVATION_FIELDS.reduce((acc, key) => {
(acc as Record<string, unknown>)[key] = row[key];
return acc;
}, {} as AnonReservation);
}本人性を証明しない検索(日付範囲やアップロードされた画像など)については、さらに人物識別情報をマスクします。"Yamada Taro" -> "Y. T." のようにです。ゲストは正しい予約を見つけられたことを確認できますが、スクレイパーは何も学習できません。
このルールの最も強い形は時間的なものです。ステートマシンが滞在を「入館許可済み」と判断するまで、ドアの認証情報はいかなるレスポンスにも存在しない。 ゲスト向けの予約一覧エンドポイントは、チェックインが完了するまで PIN と QR コードを出しません。つまり、早期に漏れた一覧は無価値になります。
function serializeReservation(r: Reservation, viewer: Viewer): ReservationDto {
const base = projectBase(r, viewer);
const admitted = r.checkinState === 'checked_in' && r.reviewState !== 'pending';
return admitted ? { ...base, credentials: r.credentials } : base;
}4. 入館判定を明示的な state machine としてモデル化する
「ホストレビュー」ステップ、つまり鍵を発行する前にスタッフがゲスト名簿を承認する工程の追加は、場当たり的な boolean が死にゆく場所です。私たちはこれを、明示的な拒否理由を持つ有限状態機械としてモデル化し、SDK では終端でないすべての状態を 未チェックイン として扱うようにしました。
export type ReviewState =
| 'not_required'
| 'pending'
| 'sent_back'
| 'approved'
| 'rejected';
const TRANSITIONS: Record<ReviewState, ReviewState[]> = {
not_required: [],
pending: ['approved', 'rejected', 'sent_back'],
sent_back: ['pending'],
approved: [],
rejected: [],
};
export function transition(from: ReviewState, to: ReviewState): ReviewState {
if (!TRANSITIONS[from].includes(to)) {
throw new InvalidTransitionError(`${from} -> ${to}`);
}
return to;
}信頼できるものにしたのは、3つの不変条件です。
- 決定を対象物に束縛する。 承認は提出された名簿のハッシュを参照します。ゲストが提出後に名簿を編集した場合、その承認はもはや適用されず、チェックインは専用の
roster_changedエラーで拒否され、SDK がそれをユーザー向けメッセージにマッピングします。 - 効果が発生する瞬間にゲートを再チェックする。 支払いと資格要件は、提出時だけでなく承認時にも再検証します。両者の間には時間が流れ、事実は変化します。
- 決定の処理中は入力をロックする。 名簿は
pendingの間イミュータブルであり、これによって TOCTOU 競合の一群がまるごと排除されます。
リカバリはハッピーパスと同じくらい重要です。「決定済み」と「鍵発行済み」の間で止まってしまった承認は、バックオフ上限付きのバックグラウンドジョブによって掃き出されます。そして、その掃き出し処理の読み取り自体が失敗した場合は、黙って何もしないのではなくアラートを発報します。
5. 共有リソースに対する冪等性と compare-and-set
プロビジョニング系の操作(プラン作成、入口作成、メディアアップロード)には、現在 client_key を持たせています。リトライは重複を生むのではなく収束します。呼び出し元が不安定なキオスク端末や自動化エージェントである場合、これは重要です。
alter table plans add column client_key text;
create unique index plans_client_key_uidx on plans (organization_id, client_key)
where client_key is not null;export async function planManage(input: PlanInput, clientKey: string) {
const existing = await findByClientKey(input.organizationId, clientKey);
if (existing) return { planId: existing.id, created: false };
return insertPlan({ ...input, clientKey });
}共有 の認証情報、たとえばキオスクのディスプレイに表示するドアごとの日次 QR のようなケースでは、冪等性だけでは不十分で、相互排他が必要です。read-then-write では、2台のキオスクが同じ日付に対して2つのトークンを発行し得ます。条件付き更新(compare-and-set)にすれば、勝者が一意に定まります。
update display_keys
set qr_token = $1, issued_at = now()
where entrance_id = $2
and day = $3
and qr_token is null
returning qr_token;返る行が0件なら、別のプロセスが勝ったということです。再読み込みしてそちらを使います。ローテーションの順序も重要です。古い鍵を失効させる前に新しい鍵を投入することで、正当なゲストが有効な認証情報を持たない時間帯が一切発生しないようにします。
6. Preview、Approve、Verify ——「たぶん適用された」は禁止
設定の一括変更については、直接の書き込みを3フェーズのパイプラインに置き換えました。
- Preview(プレビュー):意図されたすべての書き込みを、承認前に差分として描画します。計画中に書き込みは一切発生しません。
- 人間にしかできない判断には人間の承認を:本番公開、フリーエントリーのドアの有効化など、金銭や物理的アクセスに影響する操作には、機械では代替できない明示的な確認を必須とします。
- 適用後の検証:書き込み後に、料金、入口、そして 書き込んだすべての行 を読み戻します。実際に検証できた場合にのみ結果を「グリーン」として報告し、未確認の結果は「不明」として描画されます。決して成功としては表示しません。
本番環境への適用にはさらに2つの独立した鍵を要求しており、単一の認証情報が侵害されただけでは稼働中の建物を再構成できないようにしています。
type ApplyResult =
| { status: 'verified'; rows: VerifiedRow[] }
| { status: 'unverified'; reason: string }
| { status: 'failed'; error: Error };
function render(result: ApplyResult): Badge {
if (result.status === 'verified') return Badge.Green;
return Badge.Warning; // 'unverified' must never look like success
}このパイプラインを駆動する capability カタログには writtenBy という軸を追加し、書き込みを実行するコンポーネントを明示せずに効果を主張する capability があると CI が失敗するようにしました。帰属不明の書き込みこそが、システムが乖離していく原因です。
7. 秘密を漏らさないオブザーバビリティ
ゲスト向けチェックインホストにエラーレポーティングを追加し、その直後にスクラバーを追加しました。というのも、チェックインエラーの自然なペイロードには、URL 内のマジックリンクトークン、セッションパス、そしてシリアライズ結果が予測できない非プレーンオブジェクトが含まれるからです。
const TOKEN_PARAMS = ['token', 'access_token', 'code', 'otp', 'pin'];
function scrubUrl(raw: string): string {
try {
const url = new URL(raw);
for (const p of TOKEN_PARAMS) if (url.searchParams.has(p)) url.searchParams.set(p, '[redacted]');
url.hash = '';
return url.toString();
} catch {
return '[unparseable-url]';
}
}
function scrubContext(value: unknown): unknown {
if (typeof value === 'string') return value.includes('://') ? scrubUrl(value) : value;
if (value === null || ['number', 'boolean'].includes(typeof value)) return value;
return '[non-plain]'; // never serialize unknown objects into a third-party sink
}対になる関心事が監査ログです。こちらは伏字ではなく、完全であることが求められます。イベント種別のアローリストが実態から乖離しており、remote_unlock と key_revoke_failed の行を静かに捨てていました。インシデント調査でまさに必要となる2つのイベントです。アローリストは、証拠を静かに拒否し始めるまではあなたを守ってくれます。監査用のアローリストは設定ではなく、テスト済みの契約として扱いましょう。
alter table checkin_events drop constraint checkin_events_type_check;
alter table checkin_events add constraint checkin_events_type_check
check (event_type in ('checkin','checkout','key_issued','key_revoked',
'key_revoke_failed','remote_unlock','review_decided'));同様に、ベンダー API のトークンはブラウザから完全に排除しました。クライアントは当社の edge function を呼び出し、その関数がサーバーサイドで施設トークンを解決します。ブラウザが一度も保持しないトークンは、そこから持ち出されることもありません。
8. セキュリティ不変条件をコード化するテスト
ハッピーパスを検証するユニットテストでは、アクセス制御を守れません。ここで元が取れたテストは、構造的 な性質を検証するものでした。
it('every caller of the shared check-in body gates authorization first', () => {
const callers = findCallers('performCheckin');
for (const caller of callers) {
expect(caller.body).toCallBefore('assertCheckinAllowed', 'performCheckin');
}
});
it('anonymous lookup never returns credential fields', async () => {
const res = await lookupAnonymous({ date: '2026-10-26' });
for (const row of res.items) {
expect(Object.keys(row)).toEqual(expect.not.arrayContaining(['pin', 'qr', 'credentials']));
}
});また、機微な操作の 呼び出し回数(与信ホールドが何回置かれるか、開示ゲートが何回走るか)を CI のガードで固定しています。ゲートをうっかり削除してしまうリファクタリングは呼び出し回数を変化させ、機能テストがすべて通っていてもビルドを失敗させます。
Flaky なテストもまたセキュリティ上の問題です。鍵の順序を検証する前に認証リフレッシュのインターバルを停止するテストは、見栄えの問題ではありません。不安定なテストスイートは、チームに赤いビルドを無視する習慣を植え付けてしまいます。
まとめ
物理アクセスのシステムを信頼できるものにするパターンは、地味で反復的なものです。
- Fail closed. 読み取り失敗は決して空の結果ではなく、降格を永続化してもいけません。
- 認可テーブルはクライアントから書き込み不可に。 変更は、付与者を再チェックする definer 関数を経由させます。
- リソースのテナントに対して認可する。呼び出し元がどこかに所属していることを根拠にしてはいけません。
- レスポンスのフィールドはアローリストで。ステートマシンが滞在を許可するまで認証情報は出しません。
- 入館判定を明示的にモデル化し、決定を「何を決定したか」のハッシュに束縛し、効果の発生時点でゲートを再チェックし、保留中は入力をロックします。
- client key で書き込みを冪等にし、共有の認証情報には compare-and-set を使い、失効の前に発行します。
- Preview、Approve、Verify. 未検証の適用を成功として表示してはいけません。
- 外部に出すものはスクラブし、監査するものは完全に。
- フローだけでなく不変条件をテストする —— セキュリティゲートの呼び出し回数も含めて。
これらはどれも特殊な技術を必要としません。必要なのは、すべての分岐について「答えが不明なときに何が起こるか」を決めること、そしてドアが閉じたままであることを保証することです。