🎄 科学と神々株式会社 アドベントカレンダー 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
🌟 まとめ
デバイスフィンガープリントの要点:
-
目的
- デバイス数制限
- 不正利用検知
- UX向上
-
実装手法
- Browser API活用
- Canvas/WebGL
- SHA-256ハッシュ化
-
Nim/WASM実装
- クライアント側収集
- サーバー側管理
- デバイスAPI
-
プライバシー
- GDPR/CCPA対応
- ユーザー同意
- データ匿名化
前回: Day 17: 鍵管理とセキュリティ
次回: Day 19: エラーハンドリングとロギング
Happy Learning! 🎉