UnlockOS Developers
← 記事一覧に戻る

セキュリティ重要アプリでの堅牢な状態管理の構築

2026年2月2日2026年2月8日
6
80 commits
深度 8/10
state-managementsecuritytypescripttestingerror-handling

セキュリティ重要アプリケーションでの堅牢な状態管理の構築

はじめに

スマートロック管理のようなセキュリティ重要システムでは、ブラウザセッション、ネットワーク中断、ユーザーインタラクション全体にわたって一貫したアプリケーション状態を維持することが最重要です。単一の状態不整合がアクセス制御の失敗やセキュリティ脆弱性を引き起こす可能性があります。この記事では、不利な条件下でもセキュリティ保証を維持する堅牢な状態管理システムを構築するためのパターンを探ります。

状態の永続化と復旧パターン

ブラウザセッション間同期

Webベースのセキュリティシステムの最も困難な側面の一つは、複数のブラウザインスタンス間で状態の一貫性を維持することです。ユーザーはチェックインプロセス中に複数のタブを開いたり、デバイスを切り替えたりすることがよくあります。

interface CheckinState {
  sessionId: string;
  userId: string;
  facilityId: string;
  entryKeyExpiresAt: Date;
  authenticationComplete: boolean;
}

class SecureStateManager {
  private readonly STORAGE_KEY = 'secure_checkin_state';
  
  async persistState(state: CheckinState): Promise<void> {
    // Encrypt sensitive state before storage
    const encryptedState = await this.encrypt(state);
    localStorage.setItem(this.STORAGE_KEY, encryptedState);
    
    // Broadcast state change to other tabs
    this.broadcastStateChange(state.sessionId);
  }
  
  async recoverState(): Promise<CheckinState | null> {
    const stored = localStorage.getItem(this.STORAGE_KEY);
    if (!stored) return null;
    
    try {
      const decrypted = await this.decrypt(stored);
      return this.validateStateIntegrity(decrypted);
    } catch (error) {
      // Clear corrupted state to prevent security issues
      this.clearState();
      throw new StateCorruptionError('Failed to recover state');
    }
  }
  
  private validateStateIntegrity(state: any): CheckinState {
    if (!state.sessionId || !state.facilityId) {
      throw new StateValidationError('Invalid state structure');
    }
    
    // Verify expiration times haven't been tampered with
    const expiresAt = new Date(state.entryKeyExpiresAt);
    if (expiresAt < new Date()) {
      throw new StateExpirationError('State has expired');
    }
    
    return state as CheckinState;
  }
}

復旧リンクの実装

復旧メカニズムでの無限ループは、サービス拒否状態を引き起こす可能性があります。このような問題を防ぐパターンは以下の通りです:

class RecoveryLinkManager {
  private attempts = new Map<string, number>();
  private readonly MAX_ATTEMPTS = 3;
  private readonly COOLDOWN_MS = 300000; // 5 minutes
  
  async handleRecoveryAttempt(token: string, email: string): Promise<boolean> {
    const key = `${email}:${token}`;
    const attemptCount = this.attempts.get(key) || 0;
    
    if (attemptCount >= this.MAX_ATTEMPTS) {
      await this.logSecurityEvent('recovery_rate_limit', { email, token });
      throw new RateLimitError('Too many recovery attempts');
    }
    
    try {
      const isValid = await this.validateRecoveryToken(token, email);
      if (isValid) {
        this.attempts.delete(key);
        return true;
      } else {
        this.attempts.set(key, attemptCount + 1);
        this.scheduleCleanup(key);
        return false;
      }
    } catch (error) {
      await this.logSecurityEvent('recovery_validation_error', { 
        email, 
        error: error.message 
      });
      throw error;
    }
  }
  
  private scheduleCleanup(key: string): void {
    setTimeout(() => {
      this.attempts.delete(key);
    }, this.COOLDOWN_MS);
  }
}

時間ベースのセキュリティ制御

動的キー有効性管理

アクセス制御システムでのセキュリティキーは、正確に制御された有効期間を持つ必要があります。異なるシナリオには異なる有効期限戦略が必要です:

interface KeyValidityConfig {
  planType: 'one-time' | 'hourly' | 'daily';
  timezone: string;
  baseValidityMinutes: number;
}

class KeyValidityManager {
  calculateKeyExpiration(config: KeyValidityConfig): Date {
    const now = new Date();
    const userTimezone = new Intl.DateTimeFormat('en', {
      timeZone: config.timezone
    }).resolvedOptions().timeZone;
    
    switch (config.planType) {
      case 'one-time':
        // Fixed 3-minute window for immediate use
        return new Date(now.getTime() + 3 * 60 * 1000);
        
      case 'hourly':
        // Valid until end of current hour in user's timezone
        return this.getEndOfHour(now, userTimezone);
        
      case 'daily':
        // Valid until end of day in user's timezone
        return this.getEndOfDay(now, userTimezone);
        
      default:
        throw new InvalidPlanTypeError(`Unknown plan type: ${config.planType}`);
    }
  }
  
  isKeyValid(expiresAt: Date): boolean {
    return new Date() < expiresAt;
  }
  
  async refreshKeyIfNeeded(sessionId: string): Promise<EntryKey | null> {
    const currentKey = await this.getCurrentKey(sessionId);
    
    if (!currentKey || !this.isKeyValid(currentKey.expiresAt)) {
      // Log key refresh for audit trail
      await this.auditLog('key_refresh', {
        sessionId,
        reason: currentKey ? 'expired' : 'missing',
        previousExpiry: currentKey?.expiresAt
      });
      
      return this.generateNewKey(sessionId);
    }
    
    return currentKey;
  }
}

セキュリティ制約付きUI状態同期

ユーザーインターフェースは、システムのセキュリティ状態を正確に反映する必要があります。信頼性の高いUI更新を実装する方法は以下の通りです:

interface SecurityUIState {
  keyStatus: 'valid' | 'expired' | 'refreshing' | 'error';
  expiresAt: Date | null;
  canRefresh: boolean;
  lastRefresh: Date | null;
}

class SecurityUIController {
  private state: SecurityUIState = {
    keyStatus: 'expired',
    expiresAt: null,
    canRefresh: true,
    lastRefresh: null
  };
  
  async updateKeyStatus(): Promise<void> {
    try {
      this.setState({ keyStatus: 'refreshing' });
      
      const keyData = await this.keyManager.refreshKeyIfNeeded(this.sessionId);
      
      if (keyData) {
        this.setState({
          keyStatus: 'valid',
          expiresAt: keyData.expiresAt,
          lastRefresh: new Date()
        });
        
        // Schedule automatic refresh before expiration
        this.scheduleRefresh(keyData.expiresAt);
      } else {
        this.setState({ keyStatus: 'error' });
      }
    } catch (error) {
      await this.handleSecurityError(error);
      this.setState({ keyStatus: 'error' });
    }
  }
  
  private scheduleRefresh(expiresAt: Date): void {
    const refreshTime = expiresAt.getTime() - Date.now() - 30000; // 30s buffer
    
    if (refreshTime > 0) {
      setTimeout(() => {
        this.updateKeyStatus();
      }, refreshTime);
    }
  }
}

包括的なEnd-to-Endテスト

セキュリティ重要システムでは、状態遷移とエッジケースの徹底的なテストが必要です:

// キー有効性シナリオのテスト構造例
describe('Key Validity State Machine', () => {
  const testCases = [
    {
      name: 'one-time plan key expires after 3 minutes',
      planType: 'one-time',
      expectedDurationMs: 3 * 60 * 1000,
      allowRefresh: false
    },
    {
      name: 'hourly plan key valid until end of hour',
      planType: 'hourly', 
      timezone: 'Asia/Tokyo',
      allowRefresh: true
    },
    {
      name: 'expired key shows grayscale overlay',
      planType: 'one-time',
      testExpiredUI: true
    }
  ];
  
  testCases.forEach(testCase => {
    test(testCase.name, async ({ page }) => {
      await page.goto('/checkin?plan=' + testCase.planType);
      
      // Verify initial state
      const keyElement = page.locator('[data-testid="qr-code"]');
      await expect(keyElement).toBeVisible();
      
      if (testCase.expectedDurationMs) {
        // Fast-forward time to test expiration
        await page.evaluate((ms) => {
          Date.now = () => Date.now() + ms + 1000;
        }, testCase.expectedDurationMs);
        
        await page.reload();
        
        if (testCase.testExpiredUI) {
          await expect(keyElement).toHaveClass(/expired/);
        }
      }
      
      if (testCase.allowRefresh) {
        const refreshBtn = page.locator('[data-testid="refresh-key"]');
        await expect(refreshBtn).toBeVisible();
      }
    });
  });
});

エラーハンドリングと復旧

グレースフル劣化パターン

セキュリティシステムがエラーに遭遇した場合、明確な復旧パスを提供しながら安全に失敗する必要があります:

class SecureErrorHandler {
  async handleAuthenticationError(error: AuthError, context: AuthContext): Promise<RecoveryAction> {
    // Log security-relevant errors
    await this.securityLogger.log('auth_error', {
      errorType: error.constructor.name,
      userId: context.userId,
      timestamp: new Date().toISOString(),
      userAgent: context.userAgent,
      ipAddress: this.hashIP(context.ipAddress)
    });
    
    switch (error.type) {
      case 'TOKEN_EXPIRED':
        return {
          action: 'redirect_to_login',
          message: 'Your session has expired. Please log in again.',
          retryable: true
        };
        
      case 'INVALID_CREDENTIALS':
        return {
          action: 'show_error',
          message: 'Invalid credentials. Please check and try again.',
          retryable: true,
          rateLimitKey: context.userId
        };
        
      case 'ACCOUNT_LOCKED':
        return {
          action: 'show_recovery_options',
          message: 'Account temporarily locked. Use recovery email to regain access.',
          retryable: false
        };
        
      default:
        return {
          action: 'show_generic_error',
          message: 'Authentication failed. Please try again later.',
          retryable: false
        };
    }
  }
}

まとめ

セキュリティ重要アプリケーションの堅牢な状態管理を構築するには、以下への細心の注意が必要です:

  1. 状態の永続化: 整合性検証を伴う暗号化ストレージの実装
  2. セッション間同期: 複数のブラウザインスタンス間での状態調整
  3. 時間ベース制御: タイムゾーン対応の計算によるキー有効期限管理
  4. 復旧メカニズム: セキュリティを維持しながら無限ループを防止
  5. 包括的テスト: 全ての状態遷移とエッジケースのカバー
  6. エラーハンドリング: 明確な復旧パスを提供しながら安全に失敗

これらのパターンにより、不利な条件下でも、システムが信頼性の高いユーザー体験を提供しながらセキュリティ保証を維持することが保証されます。重要なのは、状態管理を単なる技術的な懸念としてではなく、認証や認可メカニズムと同じ厳密さを要求する重要なセキュリティ制御として扱うことです。

主要な発見

1
セキュリティ

暗号化状態永続化

機密性の高いアプリケーション状態は、ストレージ前に暗号化し、復旧時に改ざん防止のため整合性を検証する必要があります。

2
信頼性

復旧ループ防止

レート制限とクールダウン期間により、サービス拒否状態を引き起こす可能性のある無限復旧ループを防止できます。

3
テスト

包括的状態テスト

セキュリティ重要システムでは、エッジケースやエラー状態を含む全ての状態遷移の徹底的なテストが必要です。

4
エラーハンドリング

セキュア失敗パターン

システムは、ユーザーに明確な復旧パスを提供しながら、破損した可能性のある状態をクリアすることで安全に失敗する必要があります。