敲敲敲。谁?Touch ID。又来了。

想象一下:你正在终端工作,用op read查询1Password的密钥。需要Linear的API密钥。Touch ID。OpenRouter的。Touch ID。Gitea的。Touch ID。

半小时内它要求我验证指纹十四次。

你知道当一个安全工具在三十分钟内打断你十四次会发生什么吗?第五次时你就不再看它在要求什么了。你下意识地放上手指。“是的,随便什么,让我工作吧。”

而这正是安全性完全崩溃的地方。

授权疲劳:没人愿意正视的问题

这在安全领域有个名词:授权疲劳。这不是什么新概念。这与MFA疲劳攻击使用的原理相同:用授权请求轰炸用户,直到他们纯粹因为疲惫而接受一个。

2022年,一个17岁的孩子正是这样进入Uber内部系统的。他反复向员工发送认证推送通知,深夜时分,直到那个人为了能睡觉而接受了一个。

显然,1Password要求Touch ID不是攻击。但心理效应是相同的:它训练你不假思索地批准

这就像那些多年来出现在每个网站上的cookie横幅。一开始你会阅读它们。现在你不看就点击"全部接受"。恭喜:一个设计用来保护你隐私的机制教会了你更快地放弃你的隐私。

为什么1Password每次都要我的手指

我的设置:我使用op read从终端读取1Password的密钥。运行得很好。问题是我使用Claude Code(一个终端AI助手),它执行的每个命令都是一个新进程

1Password的生物识别会话超时是10分钟不活动,并在每次使用时刷新。理论上,不应该这么频繁地要求手指。但Claude Code不重用进程:每次需要密钥时,它启动一个新的shell,1Password将其解释为新会话。

结果:每次Claude需要密钥时都要Touch ID。这是持续性的。

解决方案:40行缓存

想法很简单:一个包装器在PATH中排在op前面。当你执行op read时,它检查是否已经缓存了新鲜的结果。如果有,直接返回而不接触1Password。如果没有,调用真正的op,缓存结果,完成。

对于任何其他子命令(op signinop item list等),直接传递给真正的op而不干预。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/bin/bash
# ~/.local/bin/op — 1Password CLI的缓存包装器
# 仅缓存'op read'。其他所有内容直接传递给真正的op。
# 可使用OP_CACHE_TTL配置缓存TTL(默认:3600s = 1h)

REAL_OP="/opt/homebrew/bin/op"
CACHE_DIR="${HOME}/.cache/op-cache"
CACHE_TTL="${OP_CACHE_TTL:-3600}"

# 仅缓存'op read'
if [[ "$1" == "read" ]]; then
    mkdir -p "$CACHE_DIR" && chmod 700 "$CACHE_DIR"

    # 所有参数的哈希作为缓存键
    CACHE_KEY=$(printf '%s\0' "$@" | shasum -a 256 | cut -d' ' -f1)
    CACHE_FILE="${CACHE_DIR}/${CACHE_KEY}"

    # 缓存命中:文件存在且未过期
    if [[ -f "$CACHE_FILE" ]]; then
        FILE_AGE=$(( $(date +%s) - $(stat -c %Y "$CACHE_FILE") ))
        if [[ $FILE_AGE -lt $CACHE_TTL ]]; then
            cat "$CACHE_FILE"
            exit 0
        fi
    fi

    # 缓存未命中或过期:调用真正的op
    RESULT=$("$REAL_OP" "$@")
    EXIT_CODE=$?

    # 仅在op成功时缓存
    if [[ $EXIT_CODE -eq 0 ]]; then
        printf '%s' "$RESULT" > "$CACHE_FILE"
        chmod 600 "$CACHE_FILE"
    fi

    printf '%s' "$RESULT"
    exit $EXIT_CODE
else
    # 任何其他子命令:直接传递
    exec "$REAL_OP" "$@"
fi

将它保存在~/.local/bin/op,给予执行权限,由于~/.local/bin在PATH中排在/opt/homebrew/bin之前,你的包装器会拦截调用。

安全决策

让我们诚实面对我们在做什么:在磁盘上以明文保存密钥。这听起来很糟糕。但让我们将其放在上下文中。

缓存什么:

  • op read的结果(个别密钥的读取)
  • 其他所有内容直接传递给真正的op

保存在哪里:

  • ~/.cache/op-cache/,权限700(仅你的用户)
  • 每个缓存文件权限600(仅你可读/写)

持续多长时间:

  • 默认1小时,可用OP_CACHE_TTL配置

文件名:

  • 是参数的SHA-256哈希,不会泄露包含哪个密钥

.env.local更危险吗? 不。完全一样。你的.env.local文件也是磁盘上具有限制性权限的明文密钥。而你在每个项目中都有这些。

比1Password已经做的更危险吗? 1Password应用在解锁时将你的vault保持在内存中解密状态。我们的缓存更受限(仅你读取过的密钥,不是整个vault)但不够复杂(磁盘 vs 内存)。

我们担心的事情(但不知道如何解决)

这是诚实的部分。我们不是安全专家。我们做出了看起来合理的决策,但可能遗漏了什么。一些疑虑:

1. 锁屏时应该清除缓存吗? 现在,如果你锁定Mac而有人访问磁盘(盗窃、evil maid),缓存的密钥就在那里。虽然如果有人能访问你的磁盘,你可能已经有更大的问题了(FileVault应该防止这种情况)。

2. 有竞态条件吗? 如果两个进程同时对同一个密钥执行op read,两者都可能尝试同时写入缓存。实际上不应该造成严重问题(最坏情况是部分读取),但这不优雅。

3. 哈希足够吗? 我们使用参数的SHA-256作为文件名。如果有人能访问~/.cache/op-cache/,他们无法知道每个文件中有什么密钥,但可以读取所有内容。权限600应该阻止这一点,但如果有受损进程以你的用户身份运行…

可以改进的地方

一些我们尚未实现的想法(暂时):

  • 过期文件的自动清理(用cronlaunchd定期清除)
  • 使用会话派生密钥的缓存加密
  • 从缓存提供密钥时的通知(在stderr中显示"(cached)")
  • 使用op cache clear或类似命令的手动失效

这里需要你的参与

听着,我写这篇文章时诚实承认自己不是安全专家。我们做出了看似明智的决策。威胁模型很清楚:保护我们免受授权疲劳而不打开明显的漏洞。

但"看似明智"和"安全"是两回事。

如果你比我们更了解安全(这不难)并且看到明显的缺陷、我们没有考虑的边缘情况,或者简单地说有更好的方法来做这件事:告诉我。真的。评论开放,我的邮箱也是。

我宁愿有人告诉我"你做的是危险的临时方案",也不愿在为时已晚时才发现。

房间里的大象

1Password应该原生解决这个问题吗?是的,可能应该。每个应用的可配置超时,或者在定义期间保持授权活跃的"工作会话"模式,将消除这个包装器的需要。

但在他们没有这样做的时候,替代方案更糟:继续每30秒放一次手指,直到你的大脑断开连接,开始不看就批准。

因为这就是过度安全的悖论:如果工具太烦人,你最终会比不使用它时更不安全。至少没有它时你意识到自己没有保护。有授权疲劳时,你以为自己受到保护,却闭着眼睛批准任何东西。

世界上最好的锁如果主人因为厌倦找钥匙而让门开着,就毫无用处。


相关: 如果你对为什么我们将所有密钥集中在1Password感兴趣,请阅读GitHub泄露的3900万个密钥。如果你想看看当你给AI太多能力时会发生什么(剧透:它发送44封虚假邮件),当你的AI变成你最大的敌人是恐怖故事。