1. 写在最前面
最近遇到一个看起来很简单、实际很容易踩坑的问题:旧电脑上的某个网站还保持着登录状态,但绑定手机暂时收不到验证码,也不记得密码,想把这个网站的登录状态迁移到另一台 Mac 上。
一开始很容易想到两个方案:复制浏览器的 Cookie,或者直接搬整个 Chrome 用户目录。前者涉及登录凭证,后者会把书签、历史记录、扩展和其他网站的登录态一起带走,范围都太大。
最后采用的方案是:只处理一个站点、只使用隔离的 Chrome 配置、通过 SSH 连接两台 Mac、让 Chrome 自己读写 Cookie,并且把「重启后还能不能用」作为最终验收标准。
注:本文只记录方法、判断过程和代码风格分析。真实用户名、主机名、IP、文件路径、Cookie 值、账号标识和内部命令都已替换或省略。文中的「某站点」仅代表本人拥有并有权使用的账号,不能用于迁移他人账号。
2. 先把问题说清楚
这里要迁移的不是账号本身,也不是密码,而是浏览器里的登录会话。
可以把它分成三层:
- 账号层:用户名、密码、绑定邮箱、绑定手机;
- 浏览器层:Cookie、Local Storage、IndexedDB、缓存等浏览器状态;
- 服务端层:服务端签发的会话、设备校验、风险控制和过期策略。
这次只触碰第二层,而且只允许触碰目标站点的 Cookie。即使 Cookie 成功复制,服务端仍然可能因为会话过期、设备变化或风险控制要求重新登录,所以「文件写进去了」不能作为成功标准。
真正的验收标准应该是:
rust
目标 Chrome 能打开目标站点
-> 页面被识别为已登录
-> 与源设备是同一个账号
-> 关闭 Chrome
-> 重新打开 Chrome
-> 仍然保持登录
3. 先看代码怎么拆
这次实际写的工具没有做成一个大脚本,而是拆成了几块相对独立的代码。这个拆分方式也比较符合笔者平时写小工具的习惯:先把权限和边界写清楚,再把核心动作拆开。
3.1 manifest 只声明必要权限
浏览器扩展的 manifest 只申请 Cookie 权限,并把 host permission 限定到目标站点:
json
{
"permissions": ["cookies"],
"host_permissions": [
"https://target.example/*",
"https://*.target.example/*"
]
}
这里没有申请所有网站的访问权限,也没有读取标签页、历史记录或文件系统。这个风格的重点不是「代码少」,而是让代码本身成为一份权限说明:工具能访问什么,不能访问什么,一眼能看出来。
3.2 UI 层只负责交互
popup.mjs 主要处理四件事:
- 读取口令;
- 调用 Cookie API;
- 触发加密文件下载或导入;
- 把成功、过期和失败数量显示出来。
加密、解密、域名校验和 Cookie 参数转换都放在另外的模块里。这样 UI 不需要知道 AES-GCM 如何工作,也不需要知道某个 Cookie 的字段细节。
代码里有一个比较明显的习惯:操作开始时先禁用按钮,结束时在 finally 中恢复:
scss
busy(true);
try {
// 执行导出或导入
} catch (error) {
show(error.message);
} finally {
busy(false);
}
这类写法很朴素,但可以避免用户连续点击产生两次导出、两次导入或两个并发状态。
3.3 核心模块强调白名单和提前失败
session.mjs 里的代码有几个特征。
第一,域名使用白名单匹配,而不是「拿到什么就处理什么」:
javascript
const isAllowedDomain = domain =>
/^(?:[a-z0-9-]+.)*target.example$/i
.test(domain.replace(/^./, ""));
第二,输入数据会先校验,再进入加密或写入流程。比如 Cookie 必须有合法的域名、路径、布尔字段和 SameSite 值;文件格式、口令长度、Cookie 数量也都有上限。
第三,大多数错误采用「提前失败」的方式:
javascript
if (cookies.length === 0) {
throw new Error("没有找到目标站点 Cookie");
}
这种风格的好处是,错误尽量发生在边界上,不让坏数据继续流入后面的浏览器操作。
3.4 加密使用平台能力,不引入第三方依赖
导出文件没有使用 npm 加密库,而是使用浏览器原生 Web Crypto API:
- PBKDF2 从临时口令派生密钥;
- 每次导出生成新的 salt 和 IV;
- 使用 AES-GCM 加密整个 Cookie 结构;
- 解密失败时统一提示「口令不正确或文件已损坏」,不泄露更多内部细节。
这里体现的是「能用平台能力就不额外引入依赖」的取向。对于一次性、本地运行的小工具,这样可以减少安装步骤和供应链风险。
4. 为什么没有直接复制 Chrome 用户目录
Chrome 的登录状态不是一个简单的明文 JSON 文件。Cookie 数据通常存储在 SQLite 数据库里,敏感字段还会受到操作系统钥匙串和浏览器加密机制保护。
直接复制整个用户目录有几个问题:
- 会把其他网站的登录态一起复制;
- 会把历史记录、扩展、缓存和本地存储一起复制;
- 源机器和目标机器的系统钥匙串、Chrome 版本或配置可能不同;
- 浏览器运行时可能正在写数据库,直接复制容易得到不一致的文件;
- 一旦目录泄露,影响范围远大于一个站点。
因此,迁移的基本原则定成了:
不复制整个浏览器
不读取其他站点
不打印 Cookie 值
不把 Cookie 写入普通日志
只在本人控制的两台设备之间传输
5. 实际迁移过程
5.1 第一步:先确认 SSH 和浏览器环境
目标机器已经配置了免密码 SSH,所以先做只读检查:
sql
ssh <user>@<target-host> 'sw_vers'
ssh <user>@<target-host> '"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --version'
这一步只确认:
- SSH 是否可用;
- 目标用户是谁;
- 目标 Mac 是否安装 Chrome;
- Chrome 是否正在运行。
检查通过以后,才创建一个专门给目标站点使用的浏览器配置目录。它与目标机器原来的 Chrome 配置分开,因此不会覆盖原有浏览器。
5.2 第二步:创建源端隔离配置
源端原来的 Chrome 仍然保持登录,但不直接拿它做批量写入。先创建一个临时隔离配置,再让 Chrome 读取经过筛选的目标站点数据。
这样做有两个好处:
- 后续测试失败时,不会把源端正在使用的 Chrome 搞坏;
- 可以明确知道临时配置里只有目标站点相关状态。
5.3 第三步:用 Chrome 自己的协议读取 Cookie
最初考虑过开发扩展,通过 chrome.cookies API 导出和导入。这个方向适合交互式使用,也能把权限限制在一个域名,但跨两台机器时还需要处理加密文件、人工传输和目标端导入。
最终验证路径采用了 Chrome DevTools Protocol。流程大致是:
rust
源端隔离 Chrome
-> Storage.getCookies
-> 过滤目标域名和未过期 Cookie
-> 保留在进程内存中
-> 通过 SSH 转发的 WebSocket 发送
-> 目标端隔离 Chrome
-> Storage.setCookies
这里最重要的一点是:Cookie 值没有输出到终端,也没有写进普通文本文件。日志只输出数量、是否匹配、是否通过验证等布尔信息。
5.4 第四步:第一次验证只证明了「内存态可用」
第一次把 Cookie 写入目标端以后,立即读取目标端 Cookie,发现认证字段可以匹配;接着打开目标站点,页面也返回了已登录状态。
如果到这里就宣布成功,其实还早了。
因为这只能证明:
Cookie 在当前 Chrome 进程的内存里有效
还不能证明:
Chrome 退出后会把它持久化到磁盘
5.5 第五步:发现 headless Chrome 的持久化问题
为了自动化验证,最初使用了 headless Chrome。它的行为很有迷惑性:
- 运行中读取 Cookie,认证字段存在;
- 打开目标站点,接口返回已登录;
- 关闭浏览器后,重新检查 Cookie 数据库,认证字段却不在了。
这次失败非常有价值,因为它把问题从「Cookie 值对不对」缩小到了「浏览器进程是否持久化」。
于是把验收标准进一步拆成两条:
css
A. 进程内验证:当前浏览器能否识别为登录状态?
B. 生命周期验证:关闭并重新打开后还能否识别为登录状态?
只有 A、B 都通过,才算迁移完成。
5.6 第六步:改用可见 Chrome,并做重启验证
随后改用目标 Mac 上的可见 Chrome 实例,仍然使用同一个隔离配置目录。写入以后先读取目标端状态,再关闭浏览器;重新启动以后再次打开目标站点,并调用一个只返回登录状态的接口进行检查。
最终验证结果可以抽象成这样:
json
{
"sourceRecognized": true,
"targetRecognized": true,
"sameAccount": true,
"sourceHttpStatus": 200,
"targetHttpStatus": 200
}
这里没有输出账号 ID、Cookie 值或页面内容,只保留了用于判断的结果。目标端原来的 Chrome 配置也没有被覆盖。
6. 这次代码风格里做得比较好的地方
6.1 责任边界清晰
大致可以分成四层:
- 权限层:manifest 约束能访问什么;
- 数据层:域名白名单、字段校验、加解密;
- 传输层:SSH 转发和 DevTools Protocol;
- 验证层:目标站点是否识别登录、重启后是否仍然有效。
不同层之间通过简单的数据结构连接,而不是让一个函数同时负责浏览器控制、加密和 UI。
6.2 正常路径短,异常路径有出口
正常情况下,代码阅读起来比较直:获取、过滤、加密、发送、验证。异常情况则通过 try/catch/finally、数量上限、格式检查和明确错误消息收敛掉。
这类小工具不一定需要复杂的框架,但需要让失败可见、可定位、可恢复。
6.3 日志遵循「足够诊断,但不泄密」
日志输出的是:
- Cookie 数量;
- 成功、失败和过期数量;
- 目标端是否匹配;
- 登录检查是否通过。
日志不输出:
- Cookie 的名称和值;
- 账号 ID;
- 页面正文;
- SSH 主机名和真实路径。
这是公开文章和实际运维里都值得保留的习惯:错误日志不是调试数据库,能不打印的秘密就不要打印。
6.4 代码仍然有改进空间
这次代码毕竟是为一次具体排障写的小工具,仍有几处可以继续改:
- 把测试脚本和正式迁移脚本分到独立目录;
- 使用统一的格式化工具和 lint 规则;
- 给 Chrome 协议调用增加更完整的错误分类;
- 把「源端、目标端、验证端」的连接管理抽象成复用模块;
- 为「写入后关闭、重启后检查」增加自动化回归测试;
- 不在生产脚本里硬编码具体站点名,改成显式配置并默认拒绝未知域名。
另外,测试代码里为了快速验证曾经出现过比较紧凑的条件表达式。它能运行,但可读性一般。正式整理时,宁愿多写两行判断,也不要让安全边界藏在一个复杂的三元表达式里。
7. 这次排障真正学到的东西
7.1 「能打开」不是「迁移完成」
浏览器、数据库、缓存和服务端会话是不同层次。一个页面当前显示已登录,只能说明此刻的请求成功;只有经过关闭、重启和再次访问,才能说明迁移结果具有可用性。
7.2 先做隔离,再做自动化
如果一开始就直接修改目标机器正在使用的 Chrome,失败以后很难判断是原有状态被破坏,还是新状态没有写进去。
隔离配置的成本很低,却能把问题变成一个可重复的小实验。
7.3 「少搬一点」本身就是安全措施
只迁移一个站点,不仅减少实现复杂度,也减少泄露面。把「只允许哪些域名」写进代码,比事后提醒自己「别误操作其他网站」可靠得多。
7.4 让验证结果比过程更重要
中间尝试了扩展、headless Chrome、可见 Chrome 和不同的 Cookie 写入方式。真正留下来的不是某一条命令,而是验收标准:
只迁移目标站点
源端不受影响
目标端能登录
目标端重启后仍能登录
日志不包含敏感数据
8. 使用时的安全边界
登录 Cookie 本质上接近「拿到就能用」的凭证,处理它时需要比普通配置文件更谨慎:
- 只在自己拥有或明确获授权的设备之间迁移;
- 不把 Cookie 粘贴到聊天、工单、代码仓库或公共网盘;
- 不把 Cookie 值写进终端日志、截图或录屏;
- 迁移完成后删除临时导出文件和测试副本;
- 不要把旧设备过早退出,先确认新设备可以独立使用;
- 会话失效时走网站官方的密码找回或账号申诉流程;
- 不要把本文中的占位命令直接用于不属于自己的账号。
即使所有步骤都正确,服务端也可能主动让会话失效。迁移登录态不是绕过账号安全机制的办法,只是把本人设备上的浏览器状态,在本人控制的另一台设备上做受限复制。
9. 反思
这次最有意思的地方,是一个看起来「复制几个 Cookie」就能解决的问题,最后却变成了一个完整的小型工程:权限边界、隔离环境、加密、SSH、浏览器协议、持久化、重启验证,一个环节都不能只凭感觉。
以前排查问题时,容易把「命令执行成功」当成「事情完成了」。这次 headless Chrome 给出的教训很直接:中间状态成功,不代表生命周期成功。
以后再遇到类似问题,应该先问四个问题:
- 我到底要迁移什么?
- 最小安全范围是什么?
- 失败时如何不影响原环境?
- 什么证据才能证明真的完成?
把这四个问题想清楚,很多看似麻烦的迁移任务,其实都可以拆成几个可验证的小步骤。
10. 碎碎念
本月的知识探索只是就先到这里啦,笔者需要去享受一下阳光 & 微风, 因为上海的秋天实在是太太太短暂啦!
- 昨天已经过去,明天尚未到来,今天仍是未知, 让我们试着过好每一个今天吧。
- 不放手的话,就没办法抓住新的东西呀!