ChatExport/ChatExportKnowledge
GitHub: ChatExport/ChatExportKnowledge
系统记录 Apple 消息数据库 sms.db 的表结构、备份布局、加密机制及各 iOS 版本间的变化,帮助开发者快速理解并正确解析 iPhone 消息备份数据。
Stars: 0 | Forks: 0
# iOS 如何存储消息,以及如何读取备份
关于 Apple 设备端消息数据库以及 iPhone 备份布局的笔记。
它们记录的是 **Apple 的格式**,而不是任何特定程序的格式。
写这些是因为搞清楚这些问题花了很长时间,而且这些内容并不保密,
只是比较分散。如果你正在开发读取 `sms.db` 的工具,这应该
能帮你省下几周的时间。
## 目录
| 文件 | 主题 |
|---|---|
| `README.md` | 消息数据库:表、列、iOS 版本间的变化以及 Apple 时间 |
| [`attributedBody.md`](attributedBody.md) | 为什么从 iOS 16 开始 `text` 列通常为 NULL,以及如何提取字符串 |
| [`manifest-structure.md`](manifest-structure.md) | 备份文件夹布局,SHA1 文件命名规则,`Manifest.db` |
| [`encrypted-backups.md`](encrypted-backups.md) | Keybag,密钥派生,单文件密钥 |
| [`what-cannot-be-recovered.md`](what-cannot-be-recovered.md) | 备份中不包含哪些内容的真实清单 |
## 数据库
消息存在于 `HomeDomain` 下 `Library/SMS/sms.db` 的 SQLite 数据库中。
历史上它在 macOS 上被称为 `chat.db`,在大多数相关文章中这两个名字
是交替使用的。它们的布局是一样的。
重要的表:
| 表 | 存储内容 |
|---|---|
| `message` | 每条消息一行,另外每个回应和每个系统事件各占一行 |
| `chat` | 每个对话(单聊或群聊)一行 |
| `handle` | 每个对话方地址(电话号码或电子邮件)一行 |
| `attachment` | 每个发送或接收的文件一行 |
| `chat_message_join` | 哪些消息属于哪个对话 |
| `chat_handle_join` | 哪些参与者属于哪个对话 |
| `message_attachment_join` | 哪些附件属于哪条消息 |
| `chat_recoverable_message_join` | iOS 16 及更高版本:最近删除中的消息 |
连接表是最让人意想不到的东西。一条消息并
不包含对话 ID:你需要通过 `chat_message_join` 来获取。群聊的
参与者不是一个列:它们是 `chat_handle_join` 中的行。
### `message` 表中值得了解的列
| 列 | 含义 |
|---|---|
| `ROWID` | 本地整数 ID,供连接表使用 |
| `guid` | 稳定的标识符,回复和回应指向的目标 |
| `text` | 消息文本。**从 iOS 16 开始通常为 NULL。** 参见 [`attributedBody.md`](attributedBody.md) |
| `attributedBody` | 当 `text` 为 NULL 时保存文本的 Blob |
| `handle_id` | 对话方,指向 `handle.ROWID`。在某些发件行中为 0 |
| `is_from_me` | 1 表示发出,0 表示接收 |
| `date` | 消息发送时间。Apple 时间,见下文 |
| `date_delivered` | 到达对方设备的时间。未确认时为 0 或 NULL |
| `date_read` | 已读时间戳。具有不对称性,见下文 |
| `date_edited` | iOS 16 及更高版本。0 或 NULL 表示从未被编辑 |
| `date_retracted` | iOS 16 及更高版本。非零表示发送者撤回了消息 |
| `associated_message_guid` | 对于回应:指向的目标,格式为 `p:0/GUID` 或 `bp:GUID` |
| `associated_message_type` | 0 为正常。2000 到 2005 表示添加回应。3000 到 3005 表示移除回应 |
| `thread_originator_guid` | iOS 14 及更高版本。此条消息回复的目标消息 |
| `message_summary_info` | Blob。编辑历史和撤回详情 |
| `item_type` | 0 代表真实消息。其他值为系统事件,如群组重命名 |
| `service` | `iMessage` 或 `SMS` |
上述表格中会导致大多数实现出现问题的两个后果:
**回应对应的是消息。** Tapback 是 `message` 表中的一行,其 `associated_message_type` 为非零值并指向另一条消息的 GUID。
如果简单地计算行数,每个对话都会被夸大。如果简单地渲染行,
每个“喜欢了一条消息”都会在记录中作为单独的一行出现。
**系统事件也是消息。** 非 0 的 `item_type` 标志着某事,
比如有人被添加到群组或群组被重命名。它们值得被显示,
但不能显示得好像有人真的这么说过一样。
### `date_read` 是不对称的
根据方向的不同,同一个列有两种不同的含义。
- 在**发出**行中,只有当接收方开启了已读回执时才会进行设置。
空值并不能证明消息未被读取。它通常
意味着对方从未开启过此功能。
- 在**接收**行中,无论任何人的设置如何,iOS 都会记录设备所有者阅读它的时间。
将两者都呈现为“阅读于”是一种误解。这是导出此数据的工具中最常见的错误。
## Apple 时间
时间戳并不是从 Unix 纪元开始计算的。它们是从
**2001-01-01T00:00:00Z**(即 Apple 或 Mac 绝对时间纪元)开始计算的。偏移量是
`978307200` 秒。
单位也发生了变化。较旧的备份以**秒**为单位存储。从 iOS 11 左右开始,
相同的列保存的是**纳秒**。
没有版本字段可供参考,因此实际的方法是看
数值大小。一个大于 `1e12` 的值不可能是秒数,因为那
大约是公元 33000 年。纳秒级时间戳大约在 `7e17` 左右。
因此,`1e12` 完美地将两者区分开来,而完全不需要知道 iOS 版本。
换算示例:
```
raw = 707521234000000000 # nanoseconds, because it exceeds 1e12
seconds = raw / 1e9 # 707521234
unix = seconds + 978307200 # 1685828434
```
零值、负值和缺失值在真实的数据库中会出现在草稿行和
损坏的行中。请将它们视为“无时间戳”,而不是 2001 年,
当然也不要因此抛出异常:一条损坏的行不应中断十万条消息的导出。
## 区分 iOS 版本
Apple 没有在这个数据库中提供可靠的 schema 版本号。可行的
方法是寻找**标记列**,因为每一代功能
都会增加一个独特的标记列。
| `message` 中的证据 | 版本 |
|---|---|
| 存在 `thread_originator_guid` **且**存在 `date_edited` | iOS 16 及更高版本 |
| 存在 `attributedBody` 或 `associated_message_guid`,缺少 `thread_originator_guid` | 大致为 iOS 11 至 15 |
| 两者都缺少 | 较旧的版本,或无法识别的布局 |
存在 `chat_recoverable_message_join` 表是一个单独的信号,
表示是“最近删除”存储,适用于 iOS 16 及更高版本。请单独
检查它是否具有 `delete_date` 列,因为该列并不总是被填充数据。
`PRAGMA table_info('message')` 足以在一次查询中收集所有这些信息。
**宁可降级也不要拒绝执行。** 无法识别的布局并不是
向用户显示空内容的充分理由。读取你能识别的内容并报告你
无法识别的内容,要比直接抛出错误信息好,因为另一端的用户通常
有真正的需求,而且只有一个备份。
## 这些笔记目前未涵盖的内容
- 数据库中记录的路径之外的设备端附件存储布局。
- `message_summary_info` 的 blob 格式细节(除了其在编辑历史中的作用)。
- 群聊重命名和参与者随时间的变化(这些被记录为系统事件,但这里未将其重构为时间轴)。
- 不同于 iMessage 行的 SMS 特定字段。
- 任何关于 Messages in iCloud 的内容,因为它是一个同步服务,而不是一种文件格式。
## 勘误
公开这些内容的原因就是为了获取勘误。这里的每一个声明要么是从
真实的备份中读取的,要么被标记为推断的,而那些推断的内容正是最需要
外部慧眼的地方。
一个可以采取行动的 issue 应该说明三点:备份来自哪个 iOS 版本,
你观察到了什么,以及你是如何观察到的(例如
`PRAGMA table_info` 的转储、行数或你运行的查询)。这些文件中的
一半陈述本质上都依赖于版本,因此没有附带版本的“这是错的”
无法与任何内容进行核对。
不清楚的文件也值得提 issue。这些笔记是为了给某人
省下半个月的时间而存在的,如果一段话需要读两遍才能理解,那它就没有达到这个目的。
## 许可证
CC BY 4.0。引用它,粘贴到你自己的文档中,翻译它,在它的基础上进行
构建。唯一的条件是注明出处,而指向此代码仓库的链接
即可满足此条件。
## 作者信息
Krzysztof Kowalski,Bokart,华沙。这些笔记源自于开发
[ChatExport](https://getchatexport.com),这是一个 Windows 程序,可以将 iPhone
备份转换为可以提交给法庭或交给律师的文档。
该程序是闭源的,这个代码库刻意没有成为进入该程序的入口。
在上面的任何地方,都没有来自该程序的函数名、文件名和设计决策。
这里描述的是 Apple 的格式,它不属于我们,从来不是
秘密,只是比较分散:一半是文档记录,一半是口口相传,而且在
很多地方都是错误的,以至于根据真实备份验证它所花的时间比阅读
它的时间还要长。这就是其他任何人都不需要再重复的部分。
标签:iOS, SQLite, 技术文档, 数字取证, 数据备份, 数据恢复, 数据解析, 自动化脚本