被人脸钱包密钥掉包的登录密码

自建 PAM 模块把人脸登录后的钱包密钥写进 PAM_AUTHTOK,位置排错,于是登录界面里输入的密码被密钥顶掉。

在上次调试好人脸识别后用着还是挺舒服的,突然有一天反欺诈把真人频繁判成了假体。 即便我在登录界面里输入正确密码,仍然提示认证失败,只有刷脸能进桌面。

先决条件

```text
CachyOS(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` 这一条栈里。

人脸的可用性会掩盖这个故障。只要人脸一直认得出来,日常使用看不出异常; 直到人脸也失败的那天,用户才会去试密码,撞上另一条已经坏掉的入口。

本机人脸失效的原因写在「通用性」一节的旁支里,属另一个故障,不是根因。

排查时间线

提示

以下是第一人称视角的修复记录,您在阅读过程中可能会感觉自己被耍了,所以这里笔者先道歉。

如果你需要省流环节,可以直接跳转到最终方案章节

确定错误路径

先看日志,看看密码到底是在哪一步被拒绝。

```text
10月 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` 覆盖掉了”。 按这个假设,加个“非空就不覆盖”的判断就能解决。

动手之前先把钱包模块的源码翻出来看它究竟做了什么:

```c
int 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` 已经校验完密码,此刻覆盖 `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 的变化打出来:

```text
pam_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 阶段执行了,却没有存下口令:

```text
pam_kwallet5(plasmalogin:auth): pam_kwallet5: pam_sm_authenticate
```

原因在上一轮留下的位置:`pam_kwallet5` 被落在了 `pam_unix` 前面, 它执行时 authtok 还是空的,`pam_sm_authenticate` 记下“没拿到密码”就返回, `kwallet5_key` 这个数据槽没人填。把它也挪到 `pam_walletkey` 之后:

```bash
journalctl -b 0 --no-pager | grep pam_kwallet5 | tail -3
# → pam_kwallet5: final socket path: /run/user/1000/kwallet5.socket
```

密码路径的钱包恢复正常。到这里得到两条硬约束:

两条拼起来,顺序只能是 `libbiopass` → `pam_unix` → `pam_walletkey` → `pam_kwallet5`。

第五轮:四条路径一测,窟窿露出来了

注销、登录、锁屏各试一遍,得到的是:

注销用人脸还是会弹 kwallet,注销用密码、锁屏用密码不会。

同一次会话里的两行日志:

```text
pam_walletkey: wallet key injected                                           ← 密码登录,钱包正常
pam_kwallet5(plasmalogin:session): open_session called without kwallet5_key  ← 人脸登录,钱包打不开
```

`libbiopass_pam.so` 标着 `sufficient`。它的语义是“成功即放行、并且终止整条栈”, 人脸认得出来时,排在它后面的 `pam_walletkey` / `pam_kwallet5` 一行都不会执行。

`sufficient` 短路与 `PAM_AUTHTOK` 覆盖,两件事在同一个线性 auth 栈里无法同时满足。 配置本身没有写错,是 auth 栈这个结构装不下这两条需求。

第六轮:绕过 authtok,自己拉起 ksecretd

auth 栈里摆不开,就把这件事挪到 session 阶段去做。`pam_kwallet5` 的 session 阶段 无非是“拿口令 → 派生密钥 → 拉起 `ksecretd`”,那就自己写个程序全做完:

  1. 读 `/etc/pam_walletkey.key` 拿钱包口令 R
  2. 读 `~/.local/share/kwalletd/kdewallet.salt`, 用 PBKDF2-HMAC-SHA512(50000 轮,56 字节)派生主密钥,与 `pam_kwallet5` 同一套算法
  3. `fork` + 降权到目标用户,`exec /usr/bin/ksecretd --pam-login <pipe_fd> <socket_fd>`, 把 56 字节密钥写进管道

PAM 侧用 `pam_exec` 调它:

```plaintext
session     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`。

装上执行,拿到的是:

```text
Configuration file "/root/.config/ksecretdrc" not writable.
Please contact your system administrator.
```

`setuid` 到目标用户了,但没改环境变量,`ksecretd` 继承的 `HOME` 还是 `/root`。 补上 `setenv("HOME", pw->pw_dir, 1)` 以及 `USER`、`LOGNAME`、`SHELL`,再跑:

```text
kwallet-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` 推出来的判断。

到这里停一下,回去翻这一整段时间的日志,里面有一行早就存在的记录:

```text
10月 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 阶段就扑空:

```text
pam_kwallet5(plasmalogin:session): open_session called without kwallet5_key
```

缺的是数据槽里那一格。 那就写一个只在 session 阶段干活的模块, 位置排在 `pam_kwallet5` 之前:

```plaintext
session     optional    pam_session_walletkey.so        ← 新增:补 kwallet5_key
-session    optional    pam_kwallet5.so         auto_start
```

模块只做三件事:

  1. 幂等检查:`pam_get_data(pamh, "kwallet5_key")` 命中就直接跳过 (密码路径已由 auth 阶段存过,于是零影响)
  2. 未命中则读 `/etc/pam_walletkey.key` 拿到 R
  3. `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:

```bash
gcc -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` 跑:

```bash
sudo bash install-session-walletkey.sh
```

它会现场重编译、备份 `/etc/pam.d/plasmalogin{,-autologin}`、 把新模块插到 `pam_kwallet5` 的 session 行之前。

验证

新模块的两条分支都要在日志里看到,它们分别证密码路径与人脸路径:

```bash
journalctl -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
```

判据是两件事:

钱包解锁成功的标志:

```text
pam_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 已被旧实例占用,不影响钱包解锁。

另外,读者要在自己机器上判断“钱包这次到底开没开”,可以看这两个东西:

```bash
pgrep -a ksecretd                              # 有人脸/密码登录后应该能看到它
ls -l /run/user/$(id -u)/kwallet5.socket       # 存在且有监听者 = 已解锁
```

通用性

改动只涉及两个 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 判假。变化的原因没有查到, 推测是有其他人出现在镜头中。

排错表

回滚

只摘掉新增的那一行:

```bash
sudo 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
```

回到安装前的完整状态(安装脚本留下的备份):

```bash
sudo 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` 之前的那版)。 那份会让密码登录再次失效。

尚未验证的地方

参考

本文引用的文章