康安消防智慧平台 — 数据加密技术方案

一、背景与目标

1.1 现状分析

本平台当前采用浏览器 localStorage 存储业务数据。虽然已在代码层面实施了多项安全加固(XSS阻断、HMAC会话签名、CSP策略等),但 localStorage 中的敏感数据仍为明文存储,浏览器控制台直接可读,不符合《个人信息保护法》的要求。

1.2 加密目标

二、技术方案设计

2.1 加密算法选择

2.2 密钥派生方案(核心)

┌─────────────────────────────────────────────────────┐ │ 密钥派生流程 │ │ │ │ 用户密码 + 固定盐(KDF) + 用户唯一标识 │ │ │ │ │ ▼ │ │ PBKDF2 / HKDF(Web Crypto API) │ │ │ │ │ ▼ │ │ 256-bit AES 加密密钥 ←── 不存在 localStorage 中 │ │ │ │ │ ▼ │ │ 加密 → 存储 ciphertext + IV + authTag │ │ 解密 → 从 sessionStorage 取密钥 → 还原明文 │ │ │ │ ⚠️ 页面刷新后密钥从 sessionStorage 恢复(短生命周期) │ │ ⚠️ 浏览器关闭后 sessionStorage 清除,密钥彻底销毁 │ └─────────────────────────────────────────────────────┘

2.3 密钥生命周期

✅ 安全优势:攻击者即使通过 XSS 获取了 localStorage 全部数据,由于 AES 加密密钥存储在 sessionStorage(且派生自密码),无法解密。唯有在用户已登录的同源 session 中才能解密数据。

2.4 加密范围分级

2.5 数据存储格式

加密后的数据在 localStorage 中的存储格式:

{
  "v": 1,                          // 版本号(支持未来算法升级)
  "kdf": "PBKDF2-SHA256",          // 密钥派生算法标识
  "enc": "AES-256-GCM",            // 加密算法标识
  "iv": "a1b2c3d4e5f6...",         // 12字节随机IV(Base64)
  "ct": "xYz9AbC...",              // 密文(Base64)
  "tag": "dEfGhI...",              // 16字节认证标签(Base64)
  "ts": 1718284800                 // 加密时间戳
}

三、实现代码(共享模块)

以下模块将集成到 shared/secure-storage.js,作为现有 shared/common.js 的补充:

/**
 * ============================================================
 * 康安消防智慧平台 — 安全存储模块 (SecureStorage v1.0)
 * ============================================================
 * 基于 Web Crypto API 的 AES-256-GCM 客户端加密存储
 * 
 * 使用方式:替换现有的 localStorage 直接读写
 *   写入: SecureStorage.set('customers', data)
 *   读取: SecureStorage.get('customers').then(data => ...)
 * 
 * 特点:
 *   - 密钥派生自用户密码(PBKDF2),不持久化
 *   - AES-256-GCM 认证加密(防篡改 + 防重放)
 *   - 按字段级别加密,非敏感数据保持明文以维持性能
 */

(function() {
  'use strict';

  const SecureStorage = {
    // ===== 配置 =====
    _KEY_NAMESPACE: '_ks_secure_key',    // sessionStorage key for derived key
    _SALT_KEY: '_ks_kdf_salt',           // localStorage key for random salt
    _ITERATIONS: 210000,                 // PBKDF2 iterations (OWASP 2024 recommended)
    _KEY_LENGTH: 256,
    
    // ===== P0 敏感字段列表 =====
    _SENSITIVE_FIELDS: [
      'contactPhone', 'contactName', 'contactPerson',
      'phone', 'mobile', 'tel',
      'amount', 'price', 'total', 'unitPrice', 'subtotal',
      'signature', 'customerPhone',
      'address', 'buildingAddress',
    ],

    /** 判断字段名是否敏感 */
    _isSensitiveField(fieldName) {
      var f = (fieldName || '').toLowerCase();
      for (var i = 0; i < this._SENSITIVE_FIELDS.length; i++) {
        if (f.indexOf(this._SENSITIVE_FIELDS[i]) !== -1) return true;
      }
      return false;
    },

    /** 初始化:生成或获取 KDF 盐值 */
    async _getOrCreateSalt() {
      var salt = localStorage.getItem(this._SALT_KEY);
      if (!salt) {
        var saltBytes = new Uint8Array(32);
        crypto.getRandomValues(saltBytes);
        salt = btoa(String.fromCharCode.apply(null, saltBytes));
        localStorage.setItem(this._SALT_KEY, salt);
      }
      return salt;
    },

    /** 从密码派生 AES 密钥(PBKDF2 → HKDF) */
    async _deriveKey(password) {
      var saltB64 = await this._getOrCreateSalt();
      var saltBytes = Uint8Array.from(atob(saltB64), c => c.charCodeAt(0));
      
      var enc = new TextEncoder();
      var keyMaterial = await crypto.subtle.importKey(
        'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']
      );
      
      return crypto.subtle.deriveKey(
        {
          name: 'PBKDF2',
          salt: saltBytes,
          iterations: this._ITERATIONS,
          hash: 'SHA-256'
        },
        keyMaterial,
        { name: 'AES-GCM', length: this._KEY_LENGTH },
        false, // non-extractable — 无法被 JS 导出
        ['encrypt', 'decrypt']
      );
    },

    /** 登录后初始化加密密钥(由 login.html 调用) */
    async init(password) {
      try {
        var key = await this._deriveKey(password);
        // 将 CryptoKey 存入 sessionStorage(导出为 raw 再存储)
        var exported = await crypto.subtle.exportKey('raw', key);
        var keyB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(exported)));
        sessionStorage.setItem(this._KEY_NAMESPACE, keyB64);
        return true;
      } catch(e) {
        console.error('[SecureStorage] 密钥初始化失败:', e.message);
        return false;
      }
    },

    /** 从 sessionStorage 恢复 CryptoKey 对象 */
    async _getKey() {
      var keyB64 = sessionStorage.getItem(this._KEY_NAMESPACE);
      if (!keyB64) return null;
      try {
        var raw = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
        return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
      } catch(e) {
        return null;
      }
    },

    /** 页面刷新后重新派生密钥(如果 sessionStorage 丢失但会话仍有效) */
    async _recoverKey() {
      var session = null;
      try {
        var raw = localStorage.getItem('kangan_session');
        if (raw) session = JSON.parse(raw);
      } catch(e) {}
      if (!session || !session._secure) return false;
      // 无法恢复 — sessionStorage 丢失意味着密码不可用
      return false;
    },

    /** 检查加密是否可用 */
    async isAvailable() {
      return !!(await this._getKey());
    },

    /** 加密字符串 */
    async _encrypt(plaintext) {
      var key = await this._getKey();
      if (!key) throw new Error('SecureStorage: 加密密钥不可用');
      
      var iv = new Uint8Array(12);
      crypto.getRandomValues(iv);
      
      var enc = new TextEncoder();
      var ciphertext = await crypto.subtle.encrypt(
        { name: 'AES-GCM', iv: iv },
        key,
        enc.encode(plaintext)
      );
      
      // ciphertext = encrypted data + 16-byte auth tag (GCM 自动附加)
      var ctBytes = new Uint8Array(ciphertext);
      var ctOnly = ctBytes.slice(0, ctBytes.length - 16);
      var tagOnly = ctBytes.slice(ctBytes.length - 16);
      
      return {
        v: 1,
        enc: 'AES-256-GCM',
        kdf: 'PBKDF2-SHA256',
        iv: btoa(String.fromCharCode.apply(null, iv)),
        ct: btoa(String.fromCharCode.apply(null, ctOnly)),
        tag: btoa(String.fromCharCode.apply(null, tagOnly)),
        ts: Math.floor(Date.now() / 1000)
      };
    },

    /** 解密字符串 */
    async _decrypt(packet) {
      if (!packet || !packet.ct) return null;
      
      var key = await this._getKey();
      if (!key) throw new Error('SecureStorage: 解密密钥不可用');
      
      var iv = Uint8Array.from(atob(packet.iv), c => c.charCodeAt(0));
      var ct = Uint8Array.from(atob(packet.ct), c => c.charCodeAt(0));
      var tag = Uint8Array.from(atob(packet.tag), c => c.charCodeAt(0));
      
      // 拼接 ciphertext + auth tag
      var combined = new Uint8Array(ct.length + tag.length);
      combined.set(ct);
      combined.set(tag, ct.length);
      
      try {
        var plaintext = await crypto.subtle.decrypt(
          { name: 'AES-GCM', iv: iv },
          key,
          combined
        );
        return new TextDecoder().decode(plaintext);
      } catch(e) {
        // Auth tag mismatch → 数据被篡改或密钥错误
        console.error('[SecureStorage] 解密失败(可能数据被篡改)');
        return null;
      }
    },

    /** 判断值是否为加密包 */
    _isEncryptedPacket(value) {
      return value && typeof value === 'object' && value.enc === 'AES-256-GCM' && value.v >= 1;
    },

    // ===== 公共 API =====

    /**
     * 安全写入 localStorage(自动加密敏感字段)
     * @param {string} key - localStorage key
     * @param {*} data - 待存储的数据
     */
    async set(key, data) {
      var processed = await this._encryptObject(data);
      localStorage.setItem(key, JSON.stringify(processed));
    },

    /**
     * 安全读取 localStorage(自动解密敏感字段)
     * @param {string} key - localStorage key
     * @returns {Promise<*>}
     */
    async get(key) {
      var raw = localStorage.getItem(key);
      if (!raw) return null;
      try {
        var parsed = JSON.parse(raw);
        return await this._decryptObject(parsed);
      } catch(e) {
        return null;
      }
    },

    /**
     * 批量写入(单次加密调用)
     * @param {Object} kv - { key: data, ... }
     */
    async setBatch(kv) {
      var keys = Object.keys(kv);
      for (var i = 0; i < keys.length; i++) {
        await this.set(keys[i], kv[keys[i]]);
      }
    },

    /**
     * 检测是否需要迁移(存在明文敏感数据)
     */
    async needsMigration() {
      var keys = Object.keys(localStorage).filter(k => !k.startsWith('_ks_'));
      for (var i = 0; i < keys.length; i++) {
        var raw = localStorage.getItem(keys[i]);
        if (!raw || raw[0] !== '{') continue;
        try {
          var obj = JSON.parse(raw);
          if (obj && !this._isEncryptedPacket(obj) && this._hasSensitiveFields(obj)) {
            return true;
          }
        } catch(e) {}
      }
      return false;
    },

    /** 遍历对象检测敏感字段 */
    _hasSensitiveFields(obj) {
      if (typeof obj !== 'object' || obj === null) return false;
      if (Array.isArray(obj)) {
        for (var i = 0; i < obj.length; i++) {
          if (this._hasSensitiveFields(obj[i])) return true;
        }
        return false;
      }
      var keys = Object.keys(obj);
      for (var j = 0; j < keys.length; j++) {
        if (this._isSensitiveField(keys[j])) return true;
        if (typeof obj[keys[j]] === 'object' && this._hasSensitiveFields(obj[keys[j]])) return true;
      }
      return false;
    },

    /** 递归加密对象中的敏感字段 */
    async _encryptObject(obj) {
      if (obj === null || obj === undefined) return obj;
      if (typeof obj !== 'object') return obj;
      if (this._isEncryptedPacket(obj)) return obj; // 已加密,跳过
      
      if (Array.isArray(obj)) {
        var arr = [];
        for (var i = 0; i < obj.length; i++) {
          arr.push(await this._encryptObject(obj[i]));
        }
        return arr;
      }
      
      var result = {};
      var keys = Object.keys(obj);
      for (var j = 0; j < keys.length; j++) {
        var k = keys[j];
        var v = obj[k];
        if (this._isSensitiveField(k) && typeof v === 'string' && v.length > 0) {
          result[k] = await this._encrypt(v);
        } else if (typeof v === 'object') {
          result[k] = await this._encryptObject(v);
        } else {
          result[k] = v;
        }
      }
      return result;
    },

    /** 递归解密对象中的加密包 */
    async _decryptObject(obj) {
      if (obj === null || obj === undefined) return obj;
      if (this._isEncryptedPacket(obj)) return await this._decrypt(obj);
      if (typeof obj !== 'object') return obj;
      
      if (Array.isArray(obj)) {
        var arr = [];
        for (var i = 0; i < obj.length; i++) {
          arr.push(await this._decryptObject(obj[i]));
        }
        return arr;
      }
      
      var result = {};
      var keys = Object.keys(obj);
      for (var j = 0; j < keys.length; j++) {
        var k = keys[j];
        var v = obj[k];
        if (this._isEncryptedPacket(v)) {
          result[k] = await this._decrypt(v);
        } else if (typeof v === 'object') {
          result[k] = await this._decryptObject(v);
        } else {
          result[k] = v;
        }
      }
      return result;
    },

    /** 清除加密密钥(登出时调用) */
    destroy() {
      sessionStorage.removeItem(this._KEY_NAMESPACE);
    }
  };

  // 暴露全局 API
  window.SecureStorage = SecureStorage;
})();
✅ 集成步骤:
1. 将上述代码保存为 shared/secure-storage.js;
2. 在 shared/common.js 中引入;
3. 修改 login.html:登录成功后调用 SecureStorage.init(password);
4. 修改各业务页面:将 localStorage.getItem/setItem 替换为 SecureStorage.get/set;
5. 修改 auth-check.js:登出时调用 SecureStorage.destroy()。

四、迁移方案

4.1 渐进式迁移策略

  1. 阶段一(本次部署):引入 SecureStorage 模块,但不强制加密。新增数据走加密通道,存量数据保持明文。
  2. 阶段二(部署后1周):运行迁移脚本,批量加密存量敏感数据。
  3. 阶段三(后端接入后):将加密密钥管理迁移至服务端,客户端仅持有临时解密密钥。

4.2 迁移脚本(阶段二执行)

// 在开发者控制台执行(需在已登录状态下)
(async function migrateToEncrypted() {
  if (!await SecureStorage.isAvailable()) {
    console.error('请先登录再执行迁移');
    return;
  }
  var keys = Object.keys(localStorage).filter(function(k) {
    return !k.startsWith('_ks_') && k !== 'kangan_session' && k !== 'kangan_session_sig';
  });
  var migrated = 0;
  for (var i = 0; i < keys.length; i++) {
    var raw = localStorage.getItem(keys[i]);
    try {
      var data = JSON.parse(raw);
      await SecureStorage.set(keys[i], data);
      migrated++;
    } catch(e) {
      console.warn('跳过无法解析的 key:', keys[i]);
    }
  }
  console.log('迁移完成,共处理 ' + migrated + '/' + keys.length + ' 个存储键');
})();

五、安全边界说明

⚠️ 重要声明:客户端加密方案提供的是数据-at-rest保护(即防止攻击者通过浏览器控制台直接读取明文)。它不能替代服务端安全措施。以下威胁仍需要服务端方案解决: 本项目以客户端加密作为过渡方案,长期目标为将核心数据迁移至后端。

六、合规对照

— 文档结束 —