0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

License System Day 18: デバイスフィンガープリント

Last updated at Posted at 2025-12-17

🎄 科学と神々株式会社 アドベントカレンダー 2025

License System Day 18: デバイスフィンガープリント


📖 今日のテーマ

今日は、**デバイス識別(Device Fingerprinting)**を学びます。

ブラウザ環境でのデバイス特定、不正利用防止、そしてプライバシーを考慮した実装方法を解説します。


🎯 デバイスフィンガープリントの目的

なぜデバイス識別が必要か?

目的:
✅ デバイス数制限の実施
   → 1ライセンスで5台まで、など

✅ 不正利用の検知
   → 同一ライセンスの大量デバイス使用を防止

✅ ユーザー体験の向上
   → デバイス自動認識、再認証不要

✅ セキュリティ強化
   → 不審なデバイスからのアクセス検知

プライバシーとのバランス

注意点:
⚠️  個人情報保護法(GDPR/CCPA)遵守
⚠️  ユーザー同意の取得
⚠️  最小限の情報収集
⚠️  透明性のある実装

🔍 ブラウザフィンガープリント手法

1. 基本的な情報収集

// ブラウザ環境での基本情報
const basicInfo = {
  userAgent: navigator.userAgent,
  language: navigator.language,
  platform: navigator.platform,
  screenResolution: `${screen.width}x${screen.height}`,
  colorDepth: screen.colorDepth,
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  cookiesEnabled: navigator.cookieEnabled,
  doNotTrack: navigator.doNotTrack
};

2. Canvas Fingerprinting

// Canvas APIを使った高精度フィンガープリント
function getCanvasFingerprint() {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  // テキストを描画
  ctx.textBaseline = 'top';
  ctx.font = '14px Arial';
  ctx.fillStyle = '#f60';
  ctx.fillRect(125, 1, 62, 20);
  ctx.fillStyle = '#069';
  ctx.fillText('License System 🔐', 2, 15);

  // 画像データをハッシュ化
  return canvas.toDataURL();
}

3. WebGL Fingerprinting

// WebGL情報を利用した識別
function getWebGLFingerprint() {
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');

  if (!gl) return null;

  const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');

  return {
    vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL),
    renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL),
    version: gl.getParameter(gl.VERSION),
    shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION)
  };
}

💻 Nim/WASM実装

クライアント側フィンガープリント生成

# client-wasm/src/device_fingerprint.nim
import std/[json, strutils, sha, base64]
when defined(js):
  import std/jsffi
  import std/jsconsole

type
  DeviceFingerprint* = object
    userAgent*: string
    language*: string
    platform*: string
    screenResolution*: string
    colorDepth*: int
    timezone*: string
    canvasHash*: string
    webglInfo*: string
    hash*: string

proc collectBrowserInfo*(): DeviceFingerprint {.exportc.} =
  ## ブラウザ情報を収集してフィンガープリント生成

  when defined(js):
    let navigator = js"navigator"
    let screen = js"screen"

    result.userAgent = $navigator.userAgent
    result.language = $navigator.language
    result.platform = $navigator.platform
    result.screenResolution = $screen.width & "x" & $screen.height
    result.colorDepth = screen.colorDepth.to(int)
    result.timezone = $js"Intl.DateTimeFormat().resolvedOptions().timeZone"

    # Canvas Fingerprint
    result.canvasHash = getCanvasFingerprint()

    # WebGL Fingerprint
    result.webglInfo = getWebGLFingerprint()

    # 全情報をハッシュ化
    result.hash = generateFingerprintHash(result)

    console.log("Device fingerprint generated: " & result.hash)

proc getCanvasFingerprint(): string =
  ## Canvas APIでフィンガープリント生成

  when defined(js):
    let canvas = js"document.createElement('canvas')"
    let ctx = canvas.getContext(cstring"2d")

    ctx.textBaseline = cstring"top"
    ctx.font = cstring"14px Arial"
    ctx.fillStyle = cstring"#f60"
    ctx.fillRect(125, 1, 62, 20)
    ctx.fillStyle = cstring"#069"
    ctx.fillText(cstring"License System 🔐", 2, 15)

    result = $canvas.toDataURL()

proc getWebGLFingerprint(): string =
  ## WebGL情報でフィンガープリント生成

  when defined(js):
    let canvas = js"document.createElement('canvas')"
    let gl = canvas.getContext(cstring"webgl")

    if gl == jsNull:
      return ""

    let debugInfo = gl.getExtension(cstring"WEBGL_debug_renderer_info")

    if debugInfo != jsNull:
      let vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)
      let renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)
      result = $vendor & "|" & $renderer
    else:
      result = ""

proc generateFingerprintHash(fp: DeviceFingerprint): string =
  ## フィンガープリント情報をSHA-256ハッシュ化

  # 全情報を連結
  let combined = fp.userAgent & "|" &
                fp.language & "|" &
                fp.platform & "|" &
                fp.screenResolution & "|" &
                $fp.colorDepth & "|" &
                fp.timezone & "|" &
                fp.canvasHash & "|" &
                fp.webglInfo

  # SHA-256ハッシュ化
  let hash = sha256.hash(combined)
  result = $hash

サーバー側デバイス管理

# server/src/device_manager.nim
import std/[json, times, db_sqlite, strutils]
import ../../shared/types

type
  DeviceManager* = ref object
    db: DbConn

  Device* = object
    deviceId*: string
    userId*: string
    fingerprint*: string
    deviceName*: string
    firstSeen*: DateTime
    lastSeen*: DateTime
    isActive*: bool

proc newDeviceManager*(db: DbConn): DeviceManager =
  result = DeviceManager(db: db)

proc registerDevice*(self: DeviceManager, userId, fingerprint, deviceName: string): string =
  ## デバイス登録または既存デバイスの取得

  # 既存デバイスをチェック
  let existingDevice = self.db.getRow(sql"""
    SELECT device_id, is_active
    FROM devices
    WHERE user_id = ? AND fingerprint = ?
  """, userId, fingerprint)

  if existingDevice[0] != "":
    # 既存デバイス
    let deviceId = existingDevice[0]
    let isActive = existingDevice[1] == "1"

    if not isActive:
      raise newException(ValueError, "Device has been revoked")

    # 最終アクセス時刻を更新
    self.db.exec(sql"""
      UPDATE devices
      SET last_seen = datetime('now')
      WHERE device_id = ?
    """, deviceId)

    return deviceId

  else:
    # 新規デバイス
    let deviceId = generateUUID()

    # デバイス数制限チェック
    if not self.checkDeviceLimit(userId):
      raise newException(ValueError, "Device limit exceeded for this plan")

    # デバイス登録
    self.db.exec(sql"""
      INSERT INTO devices (device_id, user_id, fingerprint, device_name, first_seen, last_seen, is_active)
      VALUES (?, ?, ?, ?, datetime('now'), datetime('now'), 1)
    """, deviceId, userId, fingerprint, deviceName)

    return deviceId

proc checkDeviceLimit*(self: DeviceManager, userId: string): bool =
  ## ユーザーのプランに基づくデバイス数制限チェック

  # ユーザーのプラン情報取得
  let planInfo = self.db.getRow(sql"""
    SELECT s.plan_type
    FROM subscriptions s
    WHERE s.user_id = ? AND s.status = 'active'
    ORDER BY s.end_date DESC
    LIMIT 1
  """, userId)

  if planInfo[0] == "":
    return false  # アクティブなサブスクリプションなし

  let planType = parseEnum[PlanType](planInfo[0])
  let limits = getPlanLimits(planType)

  # 現在のデバイス数をカウント
  let deviceCount = self.db.getValue(sql"""
    SELECT COUNT(*)
    FROM devices
    WHERE user_id = ? AND is_active = 1
  """, userId).parseInt()

  # 制限チェック
  return deviceCount < limits.maxDevices

proc getActiveDevices*(self: DeviceManager, userId: string): seq[Device] =
  ## ユーザーのアクティブデバイス一覧取得

  result = @[]

  let rows = self.db.getAllRows(sql"""
    SELECT device_id, fingerprint, device_name, first_seen, last_seen
    FROM devices
    WHERE user_id = ? AND is_active = 1
    ORDER BY last_seen DESC
  """, userId)

  for row in rows:
    result.add(Device(
      deviceId: row[0],
      userId: userId,
      fingerprint: row[1],
      deviceName: row[2],
      firstSeen: parse(row[3], "yyyy-MM-dd HH:mm:ss"),
      lastSeen: parse(row[4], "yyyy-MM-dd HH:mm:ss"),
      isActive: true
    ))

proc revokeDevice*(self: DeviceManager, userId, deviceId: string): bool =
  ## デバイスの無効化

  try:
    self.db.exec(sql"""
      UPDATE devices
      SET is_active = 0
      WHERE device_id = ? AND user_id = ?
    """, deviceId, userId)

    return true
  except:
    return false

API統合

# server/src/main.nim
routes:
  post "/api/v1/devices/register":
    ## デバイス登録エンドポイント

    let token = request.headers["X-Activation-Key"]
    let claims = cryptoService.verifyJWT(token)

    if claims.isNone:
      resp Http401, %*{"error": "Invalid token"}

    let userId = claims.get["user_id"].getStr()
    let body = parseJson(request.body)
    let fingerprint = body["fingerprint"].getStr()
    let deviceName = body["device_name"].getStr()

    try:
      let deviceId = deviceManager.registerDevice(userId, fingerprint, deviceName)

      resp Http200, %*{
        "device_id": deviceId,
        "message": "Device registered successfully"
      }

    except ValueError as e:
      resp Http400, %*{"error": e.msg}

  get "/api/v1/devices":
    ## ユーザーのデバイス一覧取得

    let token = request.headers["X-Activation-Key"]
    let claims = cryptoService.verifyJWT(token)

    if claims.isNone:
      resp Http401, %*{"error": "Invalid token"}

    let userId = claims.get["user_id"].getStr()
    let devices = deviceManager.getActiveDevices(userId)

    let deviceList = newJArray()
    for device in devices:
      deviceList.add(%*{
        "device_id": device.deviceId,
        "device_name": device.deviceName,
        "first_seen": device.firstSeen.format("yyyy-MM-dd HH:mm:ss"),
        "last_seen": device.lastSeen.format("yyyy-MM-dd HH:mm:ss")
      })

    resp Http200, %*{"devices": deviceList}

  delete "/api/v1/devices/@deviceId":
    ## デバイスの削除(無効化)

    let token = request.headers["X-Activation-Key"]
    let claims = cryptoService.verifyJWT(token)

    if claims.isNone:
      resp Http401, %*{"error": "Invalid token"}

    let userId = claims.get["user_id"].getStr()
    let deviceId = @"deviceId"

    let success = deviceManager.revokeDevice(userId, deviceId)

    if success:
      resp Http200, %*{"message": "Device revoked successfully"}
    else:
      resp Http500, %*{"error": "Failed to revoke device"}

🛡️ プライバシー配慮

GDPR/CCPA対応

# デバイスフィンガープリントのプライバシーポリシー
const PrivacyPolicy* = """
デバイスフィンガープリント収集について:

収集情報:
- ブラウザ種別とバージョン
- 画面解像度
- タイムゾーン
- Canvas/WebGL情報(ハッシュ化)

利用目的:
- デバイス数制限の実施
- 不正利用の検知
- サービス品質の向上

データ保管:
- 暗号化して保存
- 30日間のアクセスログ保持
- ユーザー削除時に完全削除

ユーザー権利:
- データアクセス権
- データ削除権
- データポータビリティ権
"""

# ユーザー同意取得
proc obtainUserConsent*(): bool {.exportc.} =
  when defined(js):
    let consent = js"confirm('デバイス情報を収集することに同意しますか?\n\n' + PrivacyPolicy)"
    return consent.to(bool)

データ匿名化

# フィンガープリントのハッシュ化(不可逆)
proc anonymizeFingerprint*(fingerprint: string, salt: string): string =
  ## ソルト付きハッシュで匿名化

  let combined = fingerprint & salt
  let hash = sha256.hash(combined)
  result = $hash

# サーバー側での保存
proc storeFingerprintSecurely*(userId, fingerprint: string) =
  let salt = generateSalt()  # ユーザーごとのソルト
  let anonymized = anonymizeFingerprint(fingerprint, salt)

  db.exec(sql"""
    INSERT INTO device_fingerprints (user_id, fingerprint_hash, salt)
    VALUES (?, ?, ?)
  """, userId, anonymized, salt)

🔄 衝突ハンドリング

フィンガープリント衝突の対処

proc handleFingerprintCollision*(userId, fingerprint: string): string =
  ## 同一フィンガープリントが複数ユーザーで検出された場合の処理

  # 衝突をログに記録
  logger.log(lvlWarning, "Fingerprint collision detected: " & fingerprint)

  # 追加のデバイス情報を要求
  # 例: MACアドレス、デバイスUUID(ユーザー同意必須)

  # または、ユーザーIDとフィンガープリントの複合キーを使用
  let compositeKey = userId & "|" & fingerprint
  let uniqueHash = sha256.hash(compositeKey)

  return $uniqueHash

🌟 まとめ

デバイスフィンガープリントの要点:

  1. 目的

    • デバイス数制限
    • 不正利用検知
    • UX向上
  2. 実装手法

    • Browser API活用
    • Canvas/WebGL
    • SHA-256ハッシュ化
  3. Nim/WASM実装

    • クライアント側収集
    • サーバー側管理
    • デバイスAPI
  4. プライバシー

    • GDPR/CCPA対応
    • ユーザー同意
    • データ匿名化

前回: Day 17: 鍵管理とセキュリティ
次回: Day 19: エラーハンドリングとロギング

Happy Learning! 🎉

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?