被人脸钱包密钥掉包的登录密码
自建 PAM 模块把人脸登录后的钱包密钥写进 PAM_AUTHTOK,位置排错,于是登录界面里输入的密码被密钥顶掉。
在上次调试好人脸识别后用着还是挺舒服的,突然有一天反欺诈把真人频繁判成了假体。 即便我在登录界面里输入正确密码,仍然提示认证失败,只有刷脸能进桌面。
先决条件
```textCachyOS(Arch 系)|KDE Plasma 6.7.5|Plasma Login Manager 6.7.4 Wayland|biopass-bin 1.4.1|kwallet 6.30.0|kwallet-pam 6.7.5 gcc 16.2.1|pam 1.7.2|Linux-PAM 1.7.2
```
前期配置(人脸登录、自建 `pam_walletkey` 模块、钱包密钥文件)见
人脸登录后的 KWallet 自动解锁。
本文只描述那次配置里错误的部分,以及它引发的后果。
注意
下面每一步都在改认证栈。动手前先开一个 root 的 TTY 放着别关 (`
Ctrl+Alt+F2` 登录),改坏之后就靠它进去了。 `login` / `sshd` 这两条栈里没有本文涉及的任何模块。
四条登录途径
同一台机器上有四个登录入口,我们可以先分清坏的是哪个。
| 路径 | 使用的 PAM 服务 | 状态 |
|---|---|---|
| 登录界面 · 输密码 | `/etc/pam.d/plasmalogin` |
❌ 认证失败 |
| 登录界面 · 人脸 | `/etc/pam.d/plasmalogin` |
✅ 正常 |
| 锁屏 · 输密码 | `/etc/pam.d/kde` |
✅ 正常 |
| TTY / SSH · 输密码 | `/etc/pam.d/login`、`sshd` |
✅ 正常 |
最后两行是反证:同一个密码在 TTY 与 SSH 下能通过,说明密码本身没变,
问题出在 `plasmalogin` 这一条栈里。
人脸的可用性会掩盖这个故障。只要人脸一直认得出来,日常使用看不出异常; 直到人脸也失败的那天,用户才会去试密码,撞上另一条已经坏掉的入口。
本机人脸失效的原因写在「通用性」一节的旁支里,属另一个故障,不是根因。
排查时间线
提示
以下是第一人称视角的修复记录,您在阅读过程中可能会感觉自己被耍了,所以这里笔者先道歉。
如果你需要省流环节,可以直接跳转到最终方案章节
确定错误路径
先看日志,看看密码到底是在哪一步被拒绝。
```text10月 05 12:41:55 plasmalogin-helper[2314]: [PAM] Authenticating... 10月 05 12:41:57 plasmalogin-helper[2314]: pam_unix(plasmalogin:auth): authentication failure
```
`pam_unix` 报了认证失败。这时候手上有两条线索:`plasmalogin` 栈里第一行是人脸模块,
第二行才是管密码的 `system-login`;而 TTY 与 SSH 用同一个密码能进。
再看认证栈:
```plaintext-auth optional pam_walletkey.so -auth optional pam_kwallet5.so auth sufficient libbiopass_pam.so auth include system-login
```
第一个假设是“登录界面把密码预先塞进了 `PAM_AUTHTOK`,被 `pam_walletkey` 覆盖掉了”。
按这个假设,加个“非空就不覆盖”的判断就能解决。
动手之前先把钱包模块的源码翻出来看它究竟做了什么:
```cint r = pam_set_item(pamh, PAM_AUTHTOK, key); /* key = 钱包密钥 R,48 位 hex */
```
无条件覆盖,没有“已存在就跳过”的判断。到此为止看起来都对上了。
第一轮:给模块加一个“非空就跳过”的判断
补丁按假设写出来:
```c/* 若 PAM_AUTHTOK 非空,说明已有密码,绝不覆盖 */ const char *existing = NULL; if (pam_get_item(pamh, PAM_AUTHTOK, (const void **)&existing) == PAM_SUCCESS && existing != NULL && existing[0] != '\0') { return PAM_IGNORE; }
```
编译、双路径安装,再配一个对照脚本:Step 1 用当前模块复现故障,Step 2 换上新模块看是否转好。 脚本跑完贴回来的是:
```text【Step 1】旧模块 => Authentication failure (7) ✓ 故障复现 【Step 2】新模块 => Authentication failure (7) ✗ 仍然失败 pam_walletkey: wallet key injected for user neo
```
Step 2 失败了。第一反应是“补丁没装上”,于是核对 `md5`、`strings` 里搜补丁字符串,
两样都对得上,模块确实是新的。但日志里那行注入照旧出现。
补丁模块既然装上了,就只可能是一件事:它执行的那一刻 `PAM_AUTHTOK` 是空的。
也就是说,密码并不是登录界面预先塞进去的。
回到 Linux-PAM 的源码去找密码是谁问出来的:
```c/* 节选自 linux-pam/modules/pam_unix/pam_unix_auth.c */ retval = pam_get_authtok(pamh, PAM_AUTHTOK, &p, NULL); retval = _unix_verify_password(pamh, name, p, ctrl);
```
密码是 `pam_unix` 自己通过 PAM 对话向界面要的。而 `pam_walletkey`
排在它前面,此刻 authtok 必然为空,条件永远不触发。
`pam_get_authtok()` 在 `PAM_AUTHTOK` 已非空时会直接复用、不再弹框。
把两件事连起来,故障的链条是:
```text用户输入密码 → 密码交给 PAM 对话 pam_walletkey → PAM_AUTHTOK = R ← 抢在 pam_unix 之前 pam_unix → 不弹框,直接拿 R 校验 → 认证失败
```
登录界面收到的只是“认证失败”,看不出密码被换过。
第二轮:补丁整套回退
判断转到“位置错了”之后,那个补丁在新位置上反而有害。推演一遍:
- 把模块挪到 `
pam_unix` 之后,它执行时 authtok 里已经是用户登录密码; - 补丁版会跳过注入,钱包于是拿到登录密码,而钱包是用密钥 R 重新加密的,解不开;
- 原版模块无条件注入 R,钱包拿到 R,解锁正常。
`pam_unix` 已经校验完密码,此刻覆盖 `PAM_AUTHTOK` 不影响认证结果,
正好是 `pam_kwallet5` 需要的那份数据。结论是把补丁整套回退,模块保持原样,只动 PAM 栈。
第三轮:只改顺序,密码恢复
改动只有一行:把 `pam_walletkey` 挪到 `pam_unix` 之后。验证结果如下:
```text实验 A1(walletkey 在 unix 前) => Authentication failure (7) ← 复现故障 V1(复刻栈,真实密码) => Success (0) V2(真实 plasmalogin,真实密码)=> Success (0)
```
复现与修复各有一个真值,密码登录恢复。
顺手补一个追踪模块(`pam_sm_authenticate` 里读一次 `PAM_AUTHTOK`,
把 `PAM_USER` 与口令长度写进 syslog,返回 `PAM_IGNORE`),把 authtok 的变化打出来:
```textpam_trace_authtok[1-before-unix]: authtok=NULL pam_trace_authtok[2-after-unix]: authtok=SET(len=25) ← 25 位 = 用户密码 pam_walletkey: wallet key injected for user neo pam_trace_authtok[3-after-walletkey]: authtok=SET(len=48) ← 校验完才被钱包密钥覆盖
```
修复前在同一位置打出来的是 `authtok=SET(len=48)` 紧跟 `pam_unix: authentication failure`。
两处长度不相等,`pam_unix` 手里拿的确实是钱包密钥。具体长度取决于当时的密码,
不要拿数字本身当结论。
对真实 `
plasmalogin` 服务跑过认证探针(`pam_authenticate()`,只做认证判定、 不建立会话),用错误密码与正确密码各跑一轮,上面这些时序取自它。 探针不改变任何会话状态。
第四轮:钱包又打不开了
这一轮的价值不大,笔者利用 AI 进行排查时走了弯路。
密码通了,注销再登录,KWallet 恼人的认证弹窗还是弹出。
翻这一轮的日志,`pam_kwallet5` 的 auth 阶段执行了,却没有存下口令:
```textpam_kwallet5(plasmalogin:auth): pam_kwallet5: pam_sm_authenticate
```
原因在上一轮留下的位置:`pam_kwallet5` 被落在了 `pam_unix` 前面,
它执行时 authtok 还是空的,`pam_sm_authenticate` 记下“没拿到密码”就返回,
`kwallet5_key` 这个数据槽没人填。把它也挪到 `pam_walletkey` 之后:
```bashjournalctl -b 0 --no-pager | grep pam_kwallet5 | tail -3 # → pam_kwallet5: final socket path: /run/user/1000/kwallet5.socket
```
密码路径的钱包恢复正常。到这里得到两条硬约束:
- `
pam_walletkey` 必须在 `pam_unix` 之后。 排在前面,密码被 R 掉包,密码登录失败。 - `
pam_kwallet5` 必须在 `pam_walletkey` 之后。 排在前面,auth 阶段拿不到 R,钱包打不开。
两条拼起来,顺序只能是
`libbiopass` → `pam_unix` → `pam_walletkey` → `pam_kwallet5`。
第五轮:四条路径一测,窟窿露出来了
注销、登录、锁屏各试一遍,得到的是:
注销用人脸还是会弹 kwallet,注销用密码、锁屏用密码不会。
同一次会话里的两行日志:
```textpam_walletkey: wallet key injected ← 密码登录,钱包正常 pam_kwallet5(plasmalogin:session): open_session called without kwallet5_key ← 人脸登录,钱包打不开
```
`libbiopass_pam.so` 标着 `sufficient`。它的语义是“成功即放行、并且终止整条栈”,
人脸认得出来时,排在它后面的 `pam_walletkey` / `pam_kwallet5` 一行都不会执行。
- 两个方向,各坏一条路径
- 钱包行放 `
sufficient` 之前:人脸路径正常,`pam_unix` 拿不到用户密码,密码登录失败。 - 钱包行放 `
sufficient` 之后:密码路径正常,人脸成功即终止栈,`kwallet5_key` 没人填。
- 钱包行放 `
`sufficient` 短路与 `PAM_AUTHTOK` 覆盖,两件事在同一个线性 auth 栈里无法同时满足。
配置本身没有写错,是 auth 栈这个结构装不下这两条需求。
第六轮:绕过 authtok,自己拉起 ksecretd
auth 栈里摆不开,就把这件事挪到 session 阶段去做。`pam_kwallet5` 的 session 阶段
无非是“拿口令 → 派生密钥 → 拉起 `ksecretd`”,那就自己写个程序全做完:
- 读 `
/etc/pam_walletkey.key` 拿钱包口令 R - 读 `
~/.local/share/kwalletd/kdewallet.salt`, 用 PBKDF2-HMAC-SHA512(50000 轮,56 字节)派生主密钥,与 `pam_kwallet5` 同一套算法 - `
fork` + 降权到目标用户,`exec /usr/bin/ksecretd --pam-login <pipe_fd> <socket_fd>`, 把 56 字节密钥写进管道
PAM 侧用 `pam_exec` 调它:
```plaintextsession optional pam_exec.so quiet /usr/local/libexec/kwallet-pam-unlock
```
为了一致,`pam_kwallet.c` 的 `execute_kwallet()` 里那些细节都照抄了:
pipe fd 作为参数传递而不是 `dup` 到标准输入、给无关 fd 设 `FD_CLOEXEC` 但保留 pipe fd、
socket 绑到 `$XDG_RUNTIME_DIR/kwallet5.socket`。还加了“已有活着的 `ksecretd` 就跳过”的护栏,
避免两个实例同时操作 `kdewallet.kwl`。
装上执行,拿到的是:
```textConfiguration file "/root/.config/ksecretdrc" not writable. Please contact your system administrator.
```
`setuid` 到目标用户了,但没改环境变量,`ksecretd` 继承的 `HOME` 还是 `/root`。
补上 `setenv("HOME", pw->pw_dir, 1)` 以及 `USER`、`LOGNAME`、`SHELL`,再跑:
```textkwallet-pam-unlock: ksecretd exited immediately (status=134) — wallet likely NOT unlocked pam_exec(plasmalogin:session): /usr/local/libexec/kwallet-pam-unlock failed: exit code 1
```
`134 = 128 + 6`,SIGABRT。`ksecretd` 是 Qt 程序,PAM 的 session 阶段此时图形会话还没起来,
没有 `WAYLAND_DISPLAY`、没有可用的 DBus 会话总线,它拿不到平台插件就 abort。
`
ksecretd` 的 stderr 原文没有采集到(诊断脚本写好了没用上), “缺图形环境”是从崩溃时机与 `status=134` 推出来的判断。
到这里停一下,回去翻这一整段时间的日志,里面有一行早就存在的记录:
```text10月 05 22:58:22 neo ksecretd[48357]: Using kwallet without parent window!
```
`ksecretd` 本来就能被 D-Bus 自动拉起。这一轮在重新实现一件不需要重新实现的事,
实现的细节还比原装实现更多。
第七轮:把缺的那一格数据补上
换思路:不碰 `ksecretd`,只补 `pam_kwallet5` 缺的那份数据。
它的两段式设计是这样的:
- `
pam_sm_authenticate` - 用 `
pam_get_item(PAM_AUTHTOK)` 取口令;为空则 `PAM_IGNORE`;取到后 `pam_set_data("kwallet5_key", 口令)`。 - `
pam_sm_open_session` - 读 `
pam_get_data("kwallet5_key")`,派生密钥,拉起 `ksecretd`。
人脸路径上 auth 阶段被 `sufficient` 短路,session 阶段就扑空:
```textpam_kwallet5(plasmalogin:session): open_session called without kwallet5_key
```
缺的是数据槽里那一格。 那就写一个只在 session 阶段干活的模块,
位置排在 `pam_kwallet5` 之前:
```plaintextsession optional pam_session_walletkey.so ← 新增:补 kwallet5_key -session optional pam_kwallet5.so auto_start
```
模块只做三件事:
- 幂等检查:`
pam_get_data(pamh, "kwallet5_key")` 命中就直接跳过 (密码路径已由 auth 阶段存过,于是零影响) - 未命中则读 `
/etc/pam_walletkey.key` 拿到 R - `
pam_set_data(pamh, "kwallet5_key", strdup(R), cleanup_wipe)`, 并用清理回调在句柄释放时擦除内存里的口令
取值语义核对过 KDE 上游 `pam_kwallet.c`:数据槽里放的是口令字符串,
不是派生密钥,所以这里直接放 R,不做 PBKDF2。
(`kwallet5_key` 这个键名必须与 `kwalletPamDataKey` 一致。)
装上之后,两条分支都在日志里出现了,输出见下面「验证」一节。
最终方案
`/etc/pam.d/plasmalogin` 最后长这样:
```ini#%PAM-1.0 # ---- Biopass 人脸认证(Plasma Login Manager 专用;失败自动回退密码)---- # 钱包两行放在 sufficient 之后:人脸路径被它短路、根本走不到; # 密码路径走到时 pam_unix 已经问完密码,此刻注入钱包密钥不影响认证结果。 auth sufficient libbiopass_pam.so auth include system-login -auth optional pam_walletkey.so -auth optional pam_kwallet5.so -auth optional pam_gnome_keyring.so account include system-login password include system-login -password optional pam_gnome_keyring.so use_authtok session optional pam_keyinit.so force revoke session include system-login -session optional pam_gnome_keyring.so auto_start # 人脸路径的 auth 阶段被 sufficient 短路,kwallet5_key 没人放; # 这一行在 session 阶段把它补上,解锁仍由下面的 pam_kwallet5 完成。 session optional pam_session_walletkey.so -session optional pam_kwallet5.so auto_start
```
与 09-25 那版的差别只有两处:钱包两行移到 `system-login` 之后;
session 段加一行。
安装
模块源码与安装脚本:
```c/* * pam_session_walletkey.c — 在 session 阶段把钱包口令喂给 pam_kwallet5 * * ── 它解决什么问题 ───────────────────────────────────────────────── * plasmalogin 的 auth 栈: * auth sufficient libbiopass_pam.so <- 人脸成功即【终止整个 auth 栈】 * auth include system-login * -auth optional pam_walletkey.so <- 人脸路径到不了 * -auth optional pam_kwallet5.so <- 人脸路径到不了 * * pam_kwallet5 是两段式设计: * auth 阶段:pam_get_item(PAM_AUTHTOK) -> pam_set_data("kwallet5_key") * session 阶段:pam_get_data("kwallet5_key") -> 派生密钥 -> 拉起 ksecretd * * 人脸路径被 sufficient 短路后,auth 阶段没跑,session 阶段就只说 * "open_session called without kwallet5_key" * 然后钱包锁死、弹窗。 * * ── 本模块的做法 ─────────────────────────────────────────────────── * 不重造 pam_kwallet5 的轮子(那要处理 ksecretd 的私有 pipe/socket 协议, * 实测 ksecretd 会因缺图形环境直接 SIGABRT)。改为在最合适的位置补上 * 缺失的那一环: * * 在 session 阶段读 /etc/pam_walletkey.key,把口令用 * pam_set_data(pamh, "kwallet5_key", …) 放进 PAM 句柄 * * 只要本模块排在 pam_kwallet5 的 session 行【之前】,后面的 pam_kwallet5 * 就会正常派生密钥并拉起 ksecretd —— 用 KDE 自己的、经过考验的实现。 * * ── 幂等性 ───────────────────────────────────────────────────────── * 若 auth 阶段已经存过 kwallet5_key(密码登录路径),本模块直接跳过, * 不覆盖已存在的值。因此对密码路径是零影响,只补人脸路径的缺口。 * * ── PAM 用法 ─────────────────────────────────────────────────────── * session optional pam_session_walletkey.so * * 安装位置:/usr/lib/security/pam_session_walletkey.so * * 编译: * gcc -O2 -fPIC -shared -o pam_session_walletkey.so \ * pam_session_walletkey.c $(pkg-config --cflags pam 2>/dev/null) * * SPDX-License-Identifier: MIT */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <syslog.h> #include <sys/stat.h> #include <sys/types.h> #include <pwd.h> #include <security/pam_modules.h> #include <security/_pam_types.h> #define KEYFILE_DEFAULT "/etc/pam_walletkey.key" #define MAXKEY 1024 #define DATA_KEY "kwallet5_key" /* 必须与 pam_kwallet.c 的 kwalletPamDataKey 一致 */ static void wipe(char *s) { if (!s) return; volatile char *p = s; while (*p) *(p++) = 0; } /* pam_set_data 的清理回调:PAM 释放句柄时擦除我们放进去的口令 */ static void cleanup_wipe(pam_handle_t *pamh, void *data, int error_status) { (void)pamh; (void)error_status; if (data) { wipe((char *)data); free(data); } } static int read_key(const char *path, char *buf, size_t bufsz) { struct stat st; if (stat(path, &st) != 0 || !S_ISREG(st.st_mode)) { syslog(LOG_WARNING, "pam_session_walletkey: cannot stat %s", path); return -1; } /* 不检查 st_uid==0:PAM 在 user namespace 里读到的属主可能被映射, * 检查会误判。真正要紧的是没有 group/other 权限。 */ if ((st.st_mode & (S_IRWXG | S_IRWXO)) != 0) { syslog(LOG_WARNING, "pam_session_walletkey: %s has group/other perms (mode=%o) — refusing", path, st.st_mode & 07777); return -1; } FILE *f = fopen(path, "r"); if (!f) { syslog(LOG_WARNING, "pam_session_walletkey: cannot open %s", path); return -1; } if (!fgets(buf, (int)bufsz, f)) { fclose(f); syslog(LOG_WARNING, "pam_session_walletkey: cannot read %s", path); return -1; } fclose(f); size_t n = strcspn(buf, "\r\n"); buf[n] = '\0'; return n == 0 ? -1 : 0; } PAM_EXTERN int pam_sm_open_session(pam_handle_t *pamh, int flags, int argc, const char **argv) { (void)flags; const char *keyfile = KEYFILE_DEFAULT; for (int i = 0; i < argc; i++) { if (strncmp(argv[i], "keyfile=", 8) == 0 && argv[i][8] != '\0') keyfile = argv[i] + 8; } const char *user = NULL; if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS || !user || !*user) { syslog(LOG_WARNING, "pam_session_walletkey: cannot get user; skipping"); return PAM_IGNORE; } struct passwd *pw = getpwnam(user); if (pw && pw->pw_uid == 0) { /* root 不需要解锁钱包 */ return PAM_IGNORE; } /* 幂等:auth 阶段已经存过就不动它(密码登录路径走这里) */ const void *existing = NULL; if (pam_get_data(pamh, DATA_KEY, &existing) == PAM_SUCCESS && existing) { syslog(LOG_INFO, "pam_session_walletkey: %s already set by auth phase; leaving as-is", DATA_KEY); return PAM_IGNORE; } char key[MAXKEY] = {0}; if (read_key(keyfile, key, sizeof(key)) != 0) { wipe(key); return PAM_IGNORE; /* 绝不阻断登录 */ } char *stored = strdup(key); wipe(key); if (!stored) { syslog(LOG_ERR, "pam_session_walletkey: out of memory"); return PAM_IGNORE; } int r = pam_set_data(pamh, DATA_KEY, stored, cleanup_wipe); if (r != PAM_SUCCESS) { syslog(LOG_ERR, "pam_session_walletkey: pam_set_data failed (rc=%d)", r); cleanup_wipe(pamh, stored, 0); return PAM_IGNORE; } syslog(LOG_INFO, "pam_session_walletkey: injected wallet key into %s for user=%s (len=%zu)", DATA_KEY, user, strlen(stored)); return PAM_SUCCESS; } PAM_EXTERN int pam_sm_close_session(pam_handle_t *pamh, int flags, int argc, const char **argv) { (void)pamh; (void)flags; (void)argc; (void)argv; return PAM_SUCCESS; }
```
```bash#!/bin/bash # # install-session-walletkey.sh — 安装 pam_session_walletkey(session 阶段补 kwallet5_key) # # ── 它解决什么问题 ────────────────────────────────────────────────── # plasmalogin 的 auth 栈里,libbiopass_pam.so 标着 sufficient:人脸成功即终止 # 整个 auth 栈。排在它后面的 pam_walletkey / pam_kwallet5 永远不执行,于是 # pam_kwallet5 的 session 阶段拿不到 kwallet5_key,钱包照样弹窗。 # # 而把钱包两行挪到 sufficient 之前,pam_walletkey 会抢在 pam_unix 之前把 # PAM_AUTHTOK 换成钱包密钥,密码登录被打死。两条路都通不了。 # # 解法是绕开 auth 阶段的顺序之争:认证方式定完以后,在 session 阶段把口令 # 直接补进 PAM 句柄,解锁仍由 KDE 自己的 pam_kwallet5 完成。 # # session optional pam_session_walletkey.so <- 新增:补 kwallet5_key # -session optional pam_kwallet5.so auto_start <- 原有:正常解锁 # # 幂等:auth 阶段已经存过(密码登录路径)则跳过,对密码路径零影响。 # # 用法:sudo bash install-session-walletkey.sh # # SPDX-License-Identifier: MIT set -u DEST="/usr/lib/security/pam_session_walletkey.so" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SRC="${SCRIPT_DIR}/pam_session_walletkey.c" OLD_HELPER="/usr/local/libexec/kwallet-pam-unlock" STAMP="$(date +%F)" if [[ $EUID -ne 0 ]]; then echo "需要 root,请用:sudo bash $0" >&2 exit 1 fi [[ -f "${SRC}" ]] || { echo "找不到 ${SRC}" >&2; exit 1; } echo "===================================================================" echo " 安装 pam_session_walletkey(session 阶段补 kwallet5_key)" echo "===================================================================" echo echo "[1/6] 现场重编译并安装模块" gcc -O2 -fPIC -shared -Wall -Wextra -o /tmp/pam_session_walletkey.so "${SRC}" 2>&1 \ || { echo "编译失败,中止" >&2; exit 1; } install -m 0755 -o root -g root /tmp/pam_session_walletkey.so "${DEST}" rm -f /tmp/pam_session_walletkey.so ls -la "${DEST}" echo echo "[2/6] 移除早前那版 pam_exec 钩子(若装过)" for f in /etc/pam.d/plasmalogin /etc/pam.d/plasmalogin-autologin; do if grep -q "kwallet-pam-unlock" "$f"; then grep -v "kwallet-pam-unlock" "$f" > "${f}.tmp" mv "${f}.tmp" "$f" chown root:root "$f"; chmod 644 "$f" echo " ${f}: 已移除旧钩子" else echo " ${f}: 无旧钩子" fi done # 顺手清掉那个跑不起来的 helper(源码留在归档里备查) if [[ -e "${OLD_HELPER}" ]]; then rm -f "${OLD_HELPER}" echo " 已删除 ${OLD_HELPER}" fi echo echo "[3/6] 备份当前 PAM 文件" for f in /etc/pam.d/plasmalogin /etc/pam.d/plasmalogin-autologin; do cp -a "$f" "${f}.pre-sessionwallet-${STAMP}" echo " ${f}.pre-sessionwallet-${STAMP}" done echo echo "[4/6] 在 pam_kwallet5 之前插入模块" python3 - <<'PYEOF' MODULE_LINE = "session optional pam_session_walletkey.so" for path in ("/etc/pam.d/plasmalogin", "/etc/pam.d/plasmalogin-autologin"): lines = open(path, encoding="utf-8").read().splitlines() if any("pam_session_walletkey.so" in l for l in lines): print(f" {path}: 已存在,跳过") continue out, inserted = [], False for ln in lines: # 在 pam_kwallet5 的 session 行之前插入 s = ln.strip() if (not inserted) and s.startswith("-session") and "pam_kwallet5.so" in s: out.append(MODULE_LINE) inserted = True out.append(ln) if not inserted: # 没有 pam_kwallet5 的 session 行:追加到末尾 out.append(MODULE_LINE) open(path, "w", encoding="utf-8").write("\n".join(out) + "\n") print(f" {path}: 已插入") PYEOF for f in /etc/pam.d/plasmalogin /etc/pam.d/plasmalogin-autologin; do chown root:root "$f"; chmod 644 "$f" done echo echo "[5/6] 结果" for f in /etc/pam.d/plasmalogin /etc/pam.d/plasmalogin-autologin; do echo " --- ${f} ---" grep -n -E "^\s*-?session" "$f" | sed 's/^/ /' done echo echo "[6/6] 清掉可能残留的死 socket" echo "-------------------------------------------------------------------" target="${SUDO_USER:-$(id -un)}" if pgrep -u "${target}" ksecretd >/dev/null 2>&1; then echo " ${target} 名下已跑着 ksecretd(钱包已解锁),无需处理" else runtime_dir="/run/user/$(id -u "${target}")" if [[ -e "${runtime_dir}/kwallet5.socket" ]]; then rm -f "${runtime_dir}/kwallet5.socket" && echo " 已清理 ${runtime_dir}/kwallet5.socket" else echo " ${runtime_dir} 下没有残留 socket" fi fi cat <<EOF =================================================================== 下一步验证(三条路径都要走一遍,缺一条就可能漏掉一个入口): 1. 注销 → 用【人脸】登录 → 观察是否还弹「KDE 密码库服务」 2. 注销 → 用【密码】登录 → 必须仍然正常(不能退化) 3. 锁屏 → 用密码解锁 → 必须仍然正常 验证时留意日志中新模块的痕迹: journalctl -b 0 --no-pager | grep -E 'pam_session_walletkey|pam_kwallet5|ksecretd' | tail -30 回滚(一条命令): sudo cp -a /etc/pam.d/plasmalogin.pre-sessionwallet-${STAMP} /etc/pam.d/plasmalogin sudo cp -a /etc/pam.d/plasmalogin-autologin.pre-sessionwallet-${STAMP} /etc/pam.d/plasmalogin-autologin sudo rm -f ${DEST} =================================================================== EOF
```
先确认模块能编译、且只依赖 libpam 与 libc:
```bashgcc -O2 -fPIC -shared -Wall -Wextra -o pam_session_walletkey.so pam_session_walletkey.c nm -D --defined-only pam_session_walletkey.so | grep pam_sm # → pam_sm_open_session / pam_sm_close_session nm -D --undefined-only pam_session_walletkey.so # 只应有 libc 与 libpam 的符号
```
脚本要做 root 权限的事(装模块、改两个 PAM 文件、备份),所以用 `sudo` 跑:
```bashsudo bash install-session-walletkey.sh
```
它会现场重编译、备份 `/etc/pam.d/plasmalogin{,-autologin}`、
把新模块插到 `pam_kwallet5` 的 session 行之前。
验证
新模块的两条分支都要在日志里看到,它们分别证密码路径与人脸路径:
```bashjournalctl -b 0 --no-pager | grep -E 'pam_session_walletkey|pam_walletkey|pam_kwallet5|ksecretd' | tail -30
```
```text# 人脸登录:auth 阶段被短路,本模块补上数据槽 pam_session_walletkey: injected wallet key into kwallet5_key for user=neo (len=48) pam_kwallet5(plasmalogin:session): pam_kwallet5: pam_sm_open_session # 密码登录:auth 阶段已经存过,本模块跳过 pam_walletkey: wallet key injected for user neo pam_session_walletkey: kwallet5_key already set by auth phase; leaving as-is pam_kwallet5(plasmalogin:session): pam_kwallet5: pam_sm_open_session
```
判据是两件事:
- 不再出现 `
open_session called without kwallet5_key` - 三条路径都要走一遍:人脸登录、密码登录、锁屏密码解锁。 只验钱包那一条会漏掉密码路径 —— 前面那次配置就是这么漏掉 10 天的。
钱包解锁成功的标志:
```textpam_kwallet5: final socket path: /run/user/1000/kwallet5.socket
```
`final socket path` 是 `start_kwallet()` 被调用的痕迹。
随后 `ksecretd` 的 `Failed to register with host portal ... Connection already associated with an application ID` 是 xdg-desktop-portal 的注册冲突,
同一 app ID 已被旧实例占用,不影响钱包解锁。
另外,读者要在自己机器上判断“钱包这次到底开没开”,可以看这两个东西:
```bashpgrep -a ksecretd # 有人脸/密码登录后应该能看到它 ls -l /run/user/$(id -u)/kwallet5.socket # 存在且有监听者 = 已解锁
```
通用性
- 本文的故障机制 —— ✅ 通用。任何“在 `
pam_unix` 之前往 `PAM_AUTHTOK` 写值”的模块都会打死密码登录。 - `
sufficient` 短路 —— ✅ 通用。它是 PAM 的调度语义,与发行版无关。 - `
kwallet5_key` 这个键名 —— ⚠️ 与 `kwallet-pam` 的版本绑定,升级后要重新核对上游 `kwalletPamDataKey`。 - `
ksecretd` 作为 Secret Service —— ⚠️ 本机是 Plasma 6 的默认守护进程。用 `kwalletd5` / `kwalletd6` 的环境,路径与 socket 名都不同。 - 自建模块的必要性 —— ⚠️ 只在“钱包密钥与登录密码不同值、且要保留钱包静态加密”时才需要。
- 本文的 PAM 文件 —— ❌ 别整份照抄。`
libbiopass_pam.so`、`pam_exec` 闸门那些行是本机特有的。
改动只涉及两个 PAM 文件与该模块,`/etc/pam.d/kde`(锁屏)与 `login`、`sshd` 没动。
旁支:人脸为什么会突然不认
同一时间段里人脸也失败过,那是另一个故障。`~/.local/share/com.ticklab.biopass/logs/`
里当天的记录:
```text[2026-10-05 12:40:12.792] [warning] FaceAuth: AI anti-spoofing detected spoof, score: 0.95140916 [2026-10-05 12:40:31.196] [warning] FaceAuth: AI anti-spoofing detected spoof, score: 0.8837554 [2026-10-05 11:46:19.760] [warning] FaceAuth: AI anti-spoofing detected spoof, score: 0.95708305
```
阈值是 `anti_spoofing.threshold: 0.8`,真人被判成假体,而且这类失败不重试
(配置里的 `retries: 5` 对反欺诈失败不生效)。
两天前的记录是正常的:
```text[2026-10-03 14:49:09.256] [debug] FaceAuth: AI anti-spoofing check passed [2026-10-03 14:49:09.299] [debug] FaceAuth: Recognition PASSED | score=0.5531
```
同样的相机和阈值,10-03 通过、10-05 判假。变化的原因没有查到, 推测是有其他人出现在镜头中。
排错表
- 密码登录失败,日志里 `
pam_unix(plasmalogin:auth): authentication failure` 后面没有 `wallet key injected`- 可能原因:登录界面根本没把密码交给 `
pam_unix`,或是 `pam_faillock` 已经把这个用户锁了。 - 怎么办:`
faillock --user <用户名>` 看计数;`journalctl -b | grep -E 'faillock|pam_unix\(plasmalogin'` 对齐时间。
- 可能原因:登录界面根本没把密码交给 `
- 密码登录失败,且失败前有 `
wallet key injected`- 可能原因:`
pam_walletkey` 又排到 `pam_unix` 前面去了,密码被钱包密钥顶掉。 - 怎么办:`
grep -nE 'pam_walletkey|pam_unix' /etc/pam.d/plasmalogin`,钱包行必须在 `include system-login` 之后。
- 可能原因:`
- 人脸登录后仍弹「KDE 密码库服务」
- 可能原因:`
pam_session_walletkey` 没执行,或没排在 `pam_kwallet5` 之前。 - 怎么办:`
journalctl -b | grep pam_session_walletkey`。没有任何输出说明这一行没生效; 有输出但仍是 `open_session called without kwallet5_key`,说明插入位置在 `pam_kwallet5` 之后。
- 可能原因:`
- `
pam_session_walletkey` 报 `has group/other perms ... refusing`- 可能原因:`
/etc/pam_walletkey.key` 权限被改宽了。模块按设计要求拒绝读它。 - 怎么办:`
chmod 600 /etc/pam_walletkey.key`,属主 `root:root`。模块不检查属主 UID, 因为在用户命名空间里读到的属主可能被映射。
- 可能原因:`
- 登录后钱包还是锁的,但日志一切正常
- 可能原因:`
~/.local/share/kwalletd/kdewallet.kwl` 的密码与 `/etc/pam_walletkey.key` 里的值不一致。 - 怎么办:按 KWallet 的提示手动解锁一次,然后把钱包密码重设成密钥文件里的值; 两边一致之后再试一次登录。
- 可能原因:`
回滚
只摘掉新增的那一行:
```bashsudo sed -i '/pam_session_walletkey/d' /etc/pam.d/plasmalogin /etc/pam.d/plasmalogin-autologin sudo rm -f /usr/lib/security/pam_session_walletkey.so
```
回到安装前的完整状态(安装脚本留下的备份):
```bashsudo cp -a /etc/pam.d/plasmalogin.pre-sessionwallet-<日期> /etc/pam.d/plasmalogin sudo cp -a /etc/pam.d/plasmalogin-autologin.pre-sessionwallet-<日期> /etc/pam.d/plasmalogin-autologin
```
警告
不要回滚到 09-25 那份配置(钱包两行在 `
sufficient` 之前的那版)。 那份会让密码登录再次失效。
尚未验证的地方
- `
ksecretd` 的 SIGABRT 原文:没有采集到 stderr,“缺图形环境”是推断。 - 反欺诈为什么在 10-05 突然误判:没查明。
- 故障期的失败次数:`
journalctl` 只保留到 10-05 中午, 09-25 到 10-05 之间的登录日志已经不在了,这段时间的失败次数无法从日志复核。 能复核的是机制:只要配置是那样,密码路径就不可能通过。
参考
- https://github.com/linux-pam/linux-pam/blob/master/libpam/pam_dispatch.c —— `
sufficient` 与跳转的调度实现 - https://github.com/linux-pam/linux-pam/blob/master/modules/pam_unix/pam_unix_auth.c —— `
PAM_AUTHTOK` 由 `pam_get_authtok()` 写入 - https://invent.kde.org/plasma/kwallet-pam/-/raw/master/pam_kwallet.c —— `
kwallet5_key` 的写入与读取