keyboard-interactive 认证(RFC 4256): 客户端 password 失败后 fallback 到 ki(收发 INFO_REQUEST/RESPONSE 以密码响应); 服务端方法含 keyboard-interactive 并复用用户库密码校验, psshd -kbdint 强制仅接受 ki; 验证(pssh -pwd 连 psshd -kbdint 认证成功); STATUS/AGENT/CHANGELOG 更新
这个提交包含在:
@@ -96,4 +96,10 @@ session ticket key 支持持久化到文件:新增 `paze_tls_config_set_session_
|
||||
- `pssh ssh -o ServerAliveInterval=N`(默认15s):空闲发 `keepalive@openssh.com` global request(ssh_keepalive_send, fire-and-forget),保持空闲连接不中断;服务端回包形成保活闭环。
|
||||
- `psshd` 认证失败日志对齐 OpenSSH 格式(`Invalid user`/`Failed password ... from <IP>`),并记录对端 IP(`ssh_session_set_peer_ip`),fail2ban 默认 `sshd` filter 可直接匹配封禁。
|
||||
|
||||
### 9. keyboard-interactive 认证(✅ 已完成 2026-08-14)
|
||||
|
||||
- 客户端:password 失败后 fallback 到 keyboard-interactive(RFC 4256,收发 INFO_REQUEST/RESPONSE,以密码响应)。
|
||||
- 服务端:方法含 `keyboard-interactive` 并复用用户库密码校验;`psshd -kbdint` 强制仅接受 ki。
|
||||
- 验证:`pssh -pwd` 连 `psshd -kbdint` 经 ki 认证成功。
|
||||
|
||||
|
||||
|
||||
+6
@@ -4,6 +4,12 @@
|
||||
|
||||
## [开发主线] — 2026-08-14
|
||||
|
||||
### 新增 —— keyboard-interactive 认证 (RFC 4256)
|
||||
|
||||
- 客户端:password 失败后 fallback 到 keyboard-interactive(发请求、收 `USERAUTH_INFO_REQUEST`、以密码响应首个 prompt)。
|
||||
- 服务端:方法列表含 `keyboard-interactive`,收发 `INFO_REQUEST/INFO_RESPONSE` 并复用用户库密码校验;`psshd -kbdint` 可强制仅接受 ki(测试/策略)。
|
||||
- 验证:`pssh -pwd` 连 `psshd -kbdint` 时经 keyboard-interactive 认证成功(exit 0,命令正常执行)。
|
||||
|
||||
### 新增 —— 长连接保活 + fail2ban 兼容
|
||||
|
||||
- **keepalive 保活**:`pssh ssh` 新增 `-o ServerAliveInterval=N`(默认 15s,0=禁用),空闲时定时发 `keepalive@openssh.com` global request(`ssh_keepalive_send`,fire-and-forget),保持空闲连接不被 NAT/防火墙回收;服务端对 GLOBAL_REQUEST 回包形成保活闭环。
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
## PazeSSH — SSH-2(✅ 完成)
|
||||
|
||||
- transport:版本交换、密钥交换(curve25519-sha256)、NEWKEYS、包加密
|
||||
- 认证:password / publickey(authorized_keys 校验)
|
||||
- 认证:password / **keyboard-interactive(RFC 4256,`psshd -kbdint` 仅 ki)** / publickey(authorized_keys 校验)
|
||||
- 通道:session(exec/shell 真实进程执行)、direct-tcpip(出站桥接)
|
||||
- **SFTP 子系统 v3**:OPEN/READ/WRITE/OPENDIR/READDIR/STAT/REALPATH 等,与 OpenSSH sftp / scp -s 互操作(Git OpenSSH 实测通过)
|
||||
- 客户端命令:
|
||||
|
||||
@@ -91,6 +91,7 @@ int pssh_cmd_sshd(int argc, char **argv) {
|
||||
}
|
||||
int port = 2222;
|
||||
int verbose = 0;
|
||||
int kbdint = 0;
|
||||
char users_path[1024] = "";
|
||||
char ak_path[1024] = "";
|
||||
for (int i = 1; i < argc; i++) {
|
||||
@@ -99,6 +100,8 @@ int pssh_cmd_sshd(int argc, char **argv) {
|
||||
snprintf(users_path, sizeof(users_path), "%s", argv[++i]);
|
||||
} else if (strcmp(argv[i], "-a") == 0 && i + 1 < argc) {
|
||||
snprintf(ak_path, sizeof(ak_path), "%s", argv[++i]);
|
||||
} else if (strcmp(argv[i], "-kbdint") == 0) {
|
||||
kbdint = 1; /* 仅接受 keyboard-interactive 认证 */
|
||||
} else if (strcmp(argv[i], "-v") == 0) {
|
||||
verbose = 1;
|
||||
}
|
||||
@@ -185,6 +188,7 @@ int pssh_cmd_sshd(int argc, char **argv) {
|
||||
ssh_session_set_io(s, ps, tcp_read, tcp_write);
|
||||
ssh_session_set_users(s, users, nusers);
|
||||
ssh_session_set_peer_ip(s, inet_ntoa(client.sin_addr)); /* 供失败日志/fail2ban */
|
||||
if (kbdint) ssh_session_set_kbdint_only(s, 1);
|
||||
if (verbose) ssh_session_set_verbose(s, 1);
|
||||
|
||||
ssh_server_loop(s, (const uint8_t *)ak_text, ak_len);
|
||||
|
||||
@@ -34,6 +34,8 @@ void ssh_session_set_io(ssh_session_t *s, void *ctx,
|
||||
int (*write_fn)(void *, const uint8_t *, size_t));
|
||||
/* Set peer IP(服务端 accept 后设置,用于认证失败日志的 fail2ban 兼容) */
|
||||
void ssh_session_set_peer_ip(ssh_session_t *s, const char *ip);
|
||||
/* 服务端仅接受 keyboard-interactive(1=启用,用于测试/策略) */
|
||||
void ssh_session_set_kbdint_only(ssh_session_t *s, int on);
|
||||
|
||||
/* Return the underlying socket handle for event loops:
|
||||
* Windows: SOCKET, POSIX: int fd. -1 if none. */
|
||||
|
||||
+130
-5
@@ -53,6 +53,10 @@ static int auth_service_accept(ssh_session_t *s) {
|
||||
}
|
||||
}
|
||||
|
||||
/* password 认证的前向声明(keyboard-interactive 亦使用) */
|
||||
static int auth_keyboard_interactive(ssh_session_t *s,
|
||||
const char *username, const char *password);
|
||||
|
||||
/* 密码认证 */
|
||||
static int auth_password(ssh_session_t *s,
|
||||
const char *username, const char *password) {
|
||||
@@ -95,12 +99,65 @@ int ssh_auth_client_loop(ssh_session_t *s,
|
||||
}
|
||||
|
||||
/* Try password auth */
|
||||
if (password && *password)
|
||||
return auth_password(s, username, password);
|
||||
if (password && *password) {
|
||||
if (auth_password(s, username, password) == 0) return 0;
|
||||
/* fallback:某些服务器只提供 keyboard-interactive */
|
||||
if (auth_keyboard_interactive(s, username, password) == 0) return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* keyboard-interactive (RFC 4256):发送请求并用 password 响应首个 prompt。
|
||||
* 用于服务器不支持明文 password 而只提供 keyboard-interactive 的场景。
|
||||
* 返回 0=成功,-1=失败。 */
|
||||
static int auth_keyboard_interactive(ssh_session_t *s,
|
||||
const char *username, const char *password) {
|
||||
uint8_t pkt[1024];
|
||||
size_t poff = 0;
|
||||
poff += ssh_str_write(pkt + poff, username);
|
||||
poff += ssh_str_write(pkt + poff, "ssh-connection");
|
||||
poff += ssh_str_write(pkt + poff, "keyboard-interactive");
|
||||
poff += ssh_str_write(pkt + poff, ""); /* lang */
|
||||
poff += ssh_str_write(pkt + poff, ""); /* submethods */
|
||||
if (ssh_packet_send(s, SSH_MSG_USERAUTH_REQUEST, pkt, poff) < 0) return -1;
|
||||
|
||||
for (;;) {
|
||||
uint8_t type; const uint8_t *payload; size_t plen;
|
||||
if (ssh_packet_recv(s, &type, &payload, &plen) < 0) return -1;
|
||||
if (type == SSH_MSG_USERAUTH_SUCCESS) { free((void *)payload); return 0; }
|
||||
if (type == SSH_MSG_USERAUTH_FAILURE) { free((void *)payload); return -1; }
|
||||
if (type == SSH_MSG_USERAUTH_INFO_REQUEST && plen >= 8) {
|
||||
/* name(4) instr(4) lang(4) n_prompts(4) ... */
|
||||
size_t off = 0; uint32_t ns, ni, nl;
|
||||
uint32_t npr;
|
||||
ns = paze_load32_be(payload + off); off += 4;
|
||||
off += (size_t)ns;
|
||||
ni = paze_load32_be(payload + off); off += 4;
|
||||
off += (size_t)ni;
|
||||
nl = paze_load32_be(payload + off); off += 4;
|
||||
off += (size_t)nl;
|
||||
npr = paze_load32_be(payload + off); off += 4;
|
||||
/* 响应 password(通常首个 prompt 即密码);其余填空 */
|
||||
if (npr >= 1) {
|
||||
uint8_t resp[2048];
|
||||
size_t ro = 0;
|
||||
ro += ssh_uint32_write(resp + ro, npr);
|
||||
ro += ssh_str_write(resp + ro, password ? password : "");
|
||||
for (uint32_t k = 1; k < npr; k++)
|
||||
ro += ssh_str_write(resp + ro, "");
|
||||
if (ssh_packet_send(s, SSH_MSG_USERAUTH_INFO_RESPONSE, resp, ro) < 0) {
|
||||
free((void *)payload); return -1;
|
||||
}
|
||||
}
|
||||
free((void *)payload);
|
||||
continue;
|
||||
}
|
||||
free((void *)payload);
|
||||
}
|
||||
}
|
||||
|
||||
/* 通过 SSH agent 完成认证:先逐个尝试 agent 身份,失败回退到密码 */
|
||||
int ssh_auth_client_agent_loop(ssh_session_t *s,
|
||||
const char *username,
|
||||
@@ -126,8 +183,11 @@ int ssh_auth_client_agent_loop(ssh_session_t *s,
|
||||
}
|
||||
|
||||
/* Try password auth */
|
||||
if (password && *password)
|
||||
return auth_password(s, username, password);
|
||||
if (password && *password) {
|
||||
if (auth_password(s, username, password) == 0) return 0;
|
||||
if (auth_keyboard_interactive(s, username, password) == 0) return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
@@ -147,7 +207,8 @@ int ssh_auth_server_loop(ssh_session_t *s,
|
||||
const uint8_t *authorized_keys, size_t ak_len) {
|
||||
if (!s) return -1;
|
||||
int have_ak = (authorized_keys && ak_len > 0);
|
||||
const char *methods = have_ak ? "publickey,password" : "password";
|
||||
const char *methods = have_ak ? "publickey,password,keyboard-interactive" : "password,keyboard-interactive";
|
||||
if (s->kbdint_only) methods = "keyboard-interactive";
|
||||
|
||||
/* Receive service request */
|
||||
uint8_t type; const uint8_t *payload; size_t plen;
|
||||
@@ -189,6 +250,12 @@ int ssh_auth_server_loop(ssh_session_t *s,
|
||||
memcpy(method, payload + off, mn); method[mn] = '\0';
|
||||
|
||||
if (strcmp(method, "password") == 0) {
|
||||
if (s->kbdint_only) {
|
||||
/* 策略:仅接受 keyboard-interactive */
|
||||
send_auth_failure(s, methods);
|
||||
free((void *)payload);
|
||||
continue;
|
||||
}
|
||||
off += mlen;
|
||||
/* bool + string(password) */
|
||||
if (off + 5 > plen) { free((void *)payload); continue; }
|
||||
@@ -242,6 +309,64 @@ int ssh_auth_server_loop(ssh_session_t *s,
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(method, "keyboard-interactive") == 0) {
|
||||
/* RFC 4256:发 INFO_REQUEST(1 个 prompt),收 INFO_RESPONSE 取密码校验,
|
||||
* 复用用户库密码校验(等同 password)。 */
|
||||
uint8_t ir[512];
|
||||
size_t io2 = 0;
|
||||
io2 += ssh_str_write(ir + io2, ""); /* name */
|
||||
io2 += ssh_str_write(ir + io2, ""); /* instruction */
|
||||
io2 += ssh_str_write(ir + io2, ""); /* lang */
|
||||
io2 += ssh_uint32_write(ir + io2, 1); /* num_prompts */
|
||||
io2 += ssh_str_write(ir + io2, "Password"); /* prompt */
|
||||
io2 += ssh_bool_write(ir + io2, 0); /* echo */
|
||||
if (ssh_packet_send(s, SSH_MSG_USERAUTH_INFO_REQUEST, ir, io2) < 0) {
|
||||
free((void *)payload); return -1;
|
||||
}
|
||||
/* 收 INFO_RESPONSE */
|
||||
uint8_t rtype; const uint8_t *rp; size_t rplen;
|
||||
if (ssh_packet_recv(s, &rtype, &rp, &rplen) < 0) { free((void *)payload); return -1; }
|
||||
uint8_t *rp2 = (uint8_t *)rp; /* 便于 off+len 计算 */
|
||||
char kb_pw[1024]; size_t kb_pwl = 0;
|
||||
if (rtype == SSH_MSG_USERAUTH_INFO_RESPONSE && rplen >= 4) {
|
||||
uint32_t nresp = paze_load32_be(rp2);
|
||||
size_t rkoff = 4;
|
||||
if (nresp >= 1 && rkoff + 4 <= rplen) {
|
||||
uint32_t rl = paze_load32_be(rp2 + rkoff); rkoff += 4;
|
||||
if (rkoff + rl <= rplen) {
|
||||
kb_pwl = rl < sizeof(kb_pw) - 1 ? rl : sizeof(kb_pw) - 1;
|
||||
memcpy(kb_pw, rp2 + rkoff, kb_pwl); kb_pw[kb_pwl] = '\0';
|
||||
}
|
||||
}
|
||||
} else { free((void *)payload); free((void *)rp); return -1; }
|
||||
|
||||
/* 用户库查找 + 校验 */
|
||||
const ssh_user_t *u = NULL;
|
||||
for (int i = 0; i < s->nusers; i++) {
|
||||
size_t nl = strlen(s->users[i].name);
|
||||
if (nl == ulen && memcmp(s->users[i].name, uname, ulen) == 0) { u = &s->users[i]; break; }
|
||||
}
|
||||
if (u && ssh_userdb_check(u, kb_pw) == 0) {
|
||||
memcpy(s->username, u->name, strlen(u->name) + 1);
|
||||
s->perm = u->perm;
|
||||
uint8_t success = SSH_MSG_USERAUTH_SUCCESS;
|
||||
ssh_packet_send(s, success, NULL, 0);
|
||||
free((void *)payload); free((void *)rp);
|
||||
return 0;
|
||||
}
|
||||
{
|
||||
char unamez[256];
|
||||
size_t un = ulen < sizeof(unamez) - 1 ? ulen : sizeof(unamez) - 1;
|
||||
memcpy(unamez, uname, un); unamez[un] = '\0';
|
||||
fprintf(stderr, "%s %s from %s port %u ssh2\n",
|
||||
u ? "Failed password for" : "Invalid user",
|
||||
unamez, s->peer_ip, (unsigned)s->port);
|
||||
}
|
||||
free((void *)payload); free((void *)rp);
|
||||
send_auth_failure(s, methods);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(method, "publickey") == 0) {
|
||||
off += mlen;
|
||||
if (off + 1 > plen) { free((void *)payload); continue; }
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
#define SSH_MSG_USERAUTH_SUCCESS 52
|
||||
#define SSH_MSG_USERAUTH_BANNER 53
|
||||
#define SSH_MSG_USERAUTH_PK_OK 60
|
||||
/* RFC 4256 keyboard-interactive(RFC 4252 的 PK_OK 与 INFO_REQUEST 同为 60,
|
||||
* 按认证流程上下文区分;INFO_RESPONSE 为 61) */
|
||||
#define SSH_MSG_USERAUTH_INFO_REQUEST 60
|
||||
#define SSH_MSG_USERAUTH_INFO_RESPONSE 61
|
||||
|
||||
/* Connection */
|
||||
#define SSH_MSG_GLOBAL_REQUEST 80
|
||||
@@ -283,6 +287,7 @@ typedef struct {
|
||||
char username[256];
|
||||
char hostname[256];
|
||||
char peer_ip[64]; /* 对端 IP(服务端 accept 时设置,供日志/fail2ban) */
|
||||
int kbdint_only; /* 服务端仅接受 keyboard-interactive(测试/策略用) */
|
||||
uint16_t port;
|
||||
int is_server;
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ void ssh_session_set_peer_ip(ssh_session_t *s, const char *ip) {
|
||||
s->peer_ip[n] = '\0';
|
||||
}
|
||||
|
||||
void ssh_session_set_kbdint_only(ssh_session_t *s, int on) {
|
||||
if (s) s->kbdint_only = on ? 1 : 0;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#endif
|
||||
|
||||
在新工单中引用
屏蔽一个用户