Deck 的隐私架构:剪贴板管理器如何保护它读到的一切
纵深防御——敏感内容过滤、AES-GCM 加密、Touch ID 生物识别和零宽字符隐写术。
剪贴板管理器能看到用户复制的所有内容——密码、银行卡号、身份证号、机密文件。Deck 是一款基于 Swift / SwiftUI 构建的 macOS 原生剪贴板管理应用,这种可见性意味着一项义务:应用必须保护它接触到的每一份数据。
Deck 的做法是纵深防御——多层相互重叠的保护机制,覆盖过滤、加密、访问控制和隐蔽通信。
威胁模型
Deck 的安全设计针对三类场景:(1)数据泄漏——密码或卡号等敏感内容被意外保存到剪贴板历史;(2)本地未授权访问——有人在未锁定的 Mac 上直接查看剪贴板数据库;(3)传输截获——剪贴板数据在局域网共享或粘贴到不可信渠道时被拦截。架构中的每一层都对应其中一个或多个威胁场景。
1. 多层敏感内容过滤管线
当用户从聊天窗口复制了一个银行卡号,过滤管线会在几毫秒内拦截——在这串数字触及数据库之前。
管线位于 ClipboardService.swift,在 checkForChangesInternal() 检测到剪贴板变化时触发。四道检查按从快到慢的顺序依次执行,最廉价的检查充当快速出口。
flowchart TD
A["`Clipboard Changed\n剪贴板变化`"] --> B{"`System Password\nType Marker?\n系统密码类型标记?`"}
B -->|Yes 是| SKIP["`Skip — Record Nothing\n跳过,不记录`"]
B -->|No 否| C{"`Sensitive Window\nTitle?\n敏感窗口标题?`"}
C -->|Yes 是| SKIP
C -->|No 否| D{"`Bank Card\nNumber?\n银行卡号?`"}
D -->|Yes 是| SKIP
D -->|No 否| E{"`Identity Document\nNumber?\n身份证件号?`"}
E -->|Yes 是| SKIP
E -->|No 否| F["`Record Item\n记录条目`"]第一层:系统密码类型标记
最快的检查——无需任何解析。1Password 和 macOS 钥匙串等密码管理器会为它们的剪贴板条目添加特殊的粘贴板类型标记:
社区标准)
如果 pasteboard.types 中出现以上任何一种类型,Deck 立即跳过该条目。不读取内容,不存储任何数据。
第二层:窗口标题感知自动暂停
并非所有应用都会标记敏感内容。当用户在登录表单中输入时,窗口标题通常包含特征性词汇。Deck 通过 macOS Accessibility API(AXUIElementCopyAttributeValue)读取焦点窗口标题,并与约 20 个关键词进行匹配:
password, passwd, 密码, login, signin, 登录, credential, token,
secret, keychain, 钥匙串, vault, 2fa, otp, authenticator,
验证码, private, secure, 私密, 加密为了避免在每个轮询周期都频繁调用 AX API,窗口标题按 PID 缓存,TTL 为 0.4 秒。此检查需要 AXIsProcessTrusted() 辅助功能权限,并受 smartWindowTitleDetection 偏好设置控制。
第三层:Luhn 算法银行卡检测
原始剪贴板文本中 13–19 位的数字序列(忽略空格和连字符)会经过两阶段验证:
第一阶段 — 与已知卡组织的 IIN/BIN 前缀匹配:
| Prefix / 前缀 | Network / 卡组织 |
|---|---|
| 4 | Visa |
| 51–55 | Mastercard |
| 34, 37 | American Express |
| 62 | UnionPay / 银联 |
| 6011, 65 | Discover |
| 35 | JCB |
第二阶段 — 仅当前缀匹配时,才执行 Luhn 校验:
func passesLuhnCheck(_ digits: String) -> Bool {
var sum = 0
for (index, digit) in digits.reversed().enumerated() {
if index % 2 == 1 {
let doubled = digit * 2
sum += doubled > 9 ? doubled - 9 : doubled
} else {
sum += digit
}
}
return sum % 10 == 0
}两阶段设计是刻意的:对每一串数字都执行 Luhn 校验会浪费计算资源。前缀检查充当廉价过滤器,Luhn 仅在合理候选项上运行。
第四层:多国身份证件号检测
管线的最后一道过滤器覆盖了多个国家和地区的身份证件。每个子检测器采用正则匹配 + 特定国家/地区的校验码验证:
| Document / 证件类型 | Format / 格式 | Validation / 验证方式 |
|---|---|---|
| China Mainland 18-digit / 中国大陆 18 位 | \d{17}[\dXx] |
Weighted checksum / 加权校验码 |
| China Mainland 15-digit / 中国大陆 15 位 | \d{15} |
Regex / 正则 |
| Taiwan ID / 台湾身份证 | [A-Z][12]\d{8} |
Letter-mapped weighted check / 字母映射加权 |
| Hong Kong ID / 香港身份证 | [A-Z]{1,2}\d{6}\(?[0-9A]\)? |
Letter-value weighted / 字母值加权 |
| US SSN/ITIN / 美国 SSN/ITIN | \d{3}-\d{2}-\d{4} |
Area/group/serial rules / 区域规则 |
| German Tax ID / 德国税号 | 11 digits / 11 位 | Modulo 11 / 模 11 校验 |
| Passport / 护照号 | Multi-format / 多国格式 | Pattern matching / 模式匹配 |
每个子检测器都不止于正则匹配。一个看起来像中国大陆身份证号、但未通过加权校验的字符串会被放行。这防止了订单号或时间戳等合法内容被误拦截。
2. AES-GCM 全链路加密
过滤管线处理的是已知的敏感模式,但普通剪贴板内容——笔记、代码片段、私人消息——同样需要静态加密保护。Deck 在整个数据链路中使用 AES-256-GCM,与 TLS 1.3 相同的认证加密标准。
密钥管理
256 位对称密钥在首次使用时生成,并通过 getOrCreateEncryptionKey() 存入 macOS Keychain。密钥永远不会触及文件系统。为减少 Keychain 访问开销,密钥在内存中缓存 60 秒。
数据库列级加密
当安全模式(securityModeEnabled)开启时,以下数据库列在写入 SQLite 之前会被逐列加密:
原始剪贴板数据 搜索文本 来源应用名 自定义标题 语义向量
// Encryption / 加密
let sealedBox = try AES.GCM.seal(data, using: key)
return sealedBox.combined // nonce + ciphertext + tag
// Decryption / 解密
let sealedBox = try AES.GCM.SealedBox(combined: encryptedData)
return try AES.GCM.open(sealedBox, using: key)每次调用 AES.GCM.seal 都会生成全新的随机 nonce,因此对同一明文加密两次会产生不同的密文。GCM 认证标签确保密文的任何篡改都会在解密时被发现。
Blob 文件加密
超过 512 KB(largeBlobThreshold)的剪贴板条目以独立文件而非数据库内联方式存储。在安全模式下,这些 Blob 文件在写入磁盘前同样经过 AES-GCM 加密。
局域网共享加密
当 Deck 通过 MultipeerConnectivity 在设备间同步剪贴板内容时,共享数据使用 TOTP 验证后协商的会话密钥加密,与加密链路其他部分使用相同的 CryptoKit AES.GCM 原语。
性能代价
Apple Silicon 上的 AES-GCM 有硬件加速,对于典型剪贴板条目(文本、小图片)来说单条加解密的开销可以忽略。真正的代价体现在别处:加密列会破坏 SQLite FTS5 和向量索引,迫使搜索退化为先解密再匹配的内存扫描(详见第 6 节)。60 秒密钥内存缓存和 300 条 searchTextCache 就是为了让这条退化路径保持可用的响应速度。
3. Touch ID 生物识别认证
加密覆盖了静态数据和传输中的数据,但如果有人直接走到一台未锁定的 Mac 前打开 Deck,加密就帮不上忙了。生物识别认证通过物理身份验证填补了这个缺口。
Deck 使用 LocalAuthentication 框架,采用两级策略:
回退**:.deviceOwnerAuthentication — system password when biometrics are unavailable / 生物识别不可用时使用系统密码
提供两种超时模式:
自动锁定**:5 minutes of inactivity triggers re-authentication / 5 分钟无操作后要求重新认证
每次认证**:authentication required on every app launch (authEveryTime) / 每次打开应用都要求认证
4. 零宽字符隐写
以上各层保护的是 Deck 内部的数据。隐写术则将保护向外延伸——用户可以在分享前将信息隐藏在普通文本中,这样即使传输渠道被窃听,内容也不会被识别。
零宽字符是不占据可见空间的 Unicode 码位。Deck 使用其中四个字符进行 2 bit/字符的编码:
| Character / 字符 | Code Point / 码位 | Bits / 位值 |
|---|---|---|
| ZWSP (Zero-Width Space / 零宽空格) | U+200B | 00 |
| ZWNJ (Zero-Width Non-Joiner / 零宽非连接符) | U+200C | 01 |
| ZWJ (Zero-Width Joiner / 零宽连接符) | U+200D | 10 |
| BOM (Byte Order Mark / 字节序标记) | U+FEFF | 11 |
编码流程:
Plaintext → AES-GCM Encrypt → Map Each 2-bit Pair to Zero-Width Char
→ Prepend Magic Header "DECKSTG1" → Embed into Cover Text
明文 → AES-GCM 加密 → 每 2 bit 映射为零宽字符
→ 添加魔数头部 "DECKSTG1" → 嵌入载体文本结果是一段看起来完全正常的文本——"会议改到下午 3 点"——但其中包含了一段不可见的加密载荷,只有 Deck 能提取。
5. 图片 LSB 隐写
当需要更大的载荷容量,或文本不适合作为载体时,Deck 支持图片最低有效位(LSB)隐写。
该算法修改每个非透明像素各 RGB 通道的最低位,每像素可存储 3 bit。一张 1920×1080 无透明像素的图片可承载约 760 KB 的隐藏数据——对大多数消息来说绰绰有余。
其流程与零宽字符方案类似:
明文 → AES-GCM 加密
添加 DECKSTG1 魔数头部(14 字节,含载荷长度)
将加密后的 bit 写入像素 RGB 通道最低位
通过 CGContext 重新编码图像
透明像素(alpha < 1)被跳过——修改它们会产生可见的视觉伪影。载体图片上限约为 25 MB。
隐写密钥管理
隐写加密使用专用密钥,通过 HKDF-SHA256 从主密钥派生(SteganographyKeyStore.swift)。这种分离确保一个密钥被泄露时不会波及另一个。
6. 安全的代价:安全模式下的搜索降级
安全总有代价。当安全模式加密数据库列后,多种搜索能力失去了对明文的访问,必须优雅降级:
| Search Layer / 搜索层 | Normal Mode / 正常模式 | Security Mode / 安全模式 |
|---|---|---|
| FTS5 Full-Text Search / FTS5 全文检索 | Available / 可用 | Disabled — search_text encrypted / 索引失效 |
| sqlite-vec Vector Search / sqlite-vec 向量检索 | Available / 可用 | Disabled — vecIndexEnabled = false |
| Semantic Search / 语义搜索 | NLEmbedding | Degraded to plain-text matching / 降级为纯文本匹配 |
| Fuzzy Search / 模糊搜索 | FuseSearch | Available — in-memory after decryption / 内存解密后搜索 |
| SQL LIKE | Available / 可用 | Disabled — columns encrypted / 列已加密 |
| In-Memory Search / 内存搜索 | Fallback / 备用 | Primary — searchWithLike / 主力 |
在安全模式下,搜索退化为内存扫描:条目被即时解密并在内存中匹配。为保持可用性,扫描上限为 max(5000, limit × 200) 条(硬上限 20,000 条),searchTextCache 缓存最近 300 条已解密文本以避免重复解密。
速度不如 FTS5,但在不向磁盘写入明文搜索索引的前提下保留了功能可用性。
7. 各层如何协同工作
每一层对应不同的威胁面。过滤将敏感数据挡在系统之外;加密保护已存储的内容;生物识别阻止未经授权的物理访问;隐写术保护离开 Deck 之后的数据。单独任何一层都不够用——它们的价值在于重叠。
graph LR
A["`Clipboard Input\n剪贴板输入`"] --> B["`Sensitive Content\nFiltering\n敏感内容过滤`"]
B --> C["`AES-GCM\nEncryption\nAES-GCM 加密`"]
C --> D["`Keychain Key\nManagement\n钥匙串密钥管理`"]
C --> E["`Blob File\nEncryption\nBlob 文件加密`"]
C --> F["`LAN Sharing\nEncryption\n局域网共享加密`"]
A --> G["`Touch ID\nBiometrics\n生物识别`"]
A --> H["`Steganography\n隐写术`"]
H --> H1["`Zero-Width\nChars\n零宽字符`"]
H --> H2["`Image LSB\n图片 LSB`"]- 过滤在入口处拦截密码、银行卡号、身份证号,阻止它们进入存储。
- 加密覆盖数据库列、磁盘 Blob 文件和局域网传输,保护所有已存储和在途数据。
- 生物识别在有人坐在未锁定的 Mac 前时,要求 Touch ID 或密码才能查看历史。
- 隐写术让用户在不可信的渠道上分享内容时,多一层不可见的加密保护。
- 开启安全模式后搜索自动降级为内存扫描,避免在磁盘上留下明文索引。
Deck 会读取用户复制的所有内容。上述架构确保这种可见性不会变成安全负担——每一层削减不同类别的风险,合在一起覆盖了任何单一机制留下的空隙。