Explorar o código

支持密码保险箱 SSH 与 SFTP 密码认证

Samien hai 1 mes
pai
achega
55ed8e1607

+ 2 - 2
docs/credential-vault.md

@@ -1,6 +1,6 @@
 # Bot 内置密码库(首期)
 
-默认安装提供夭夭服务器本机密码保险箱:登录后在「Bot 模式 → 设置 → 密码管理」创建主密码、手动解锁,即可添加网站密码和 SSH 私钥,无需先安装独立密码库服务。每个账号独立存储,密文位于夭夭数据目录的 `credential-vault/`;单实例锁防止其他 broker 同时写入。默认锁定,重启不自动解锁。它不向 Bot 提供秘密读取、任务租约或自动登录执行。
+默认安装提供夭夭服务器本机密码保险箱:登录后在「Bot 模式 → 设置 → 密码管理」创建主密码、手动解锁,即可添加网站密码、SSH 登录密码和私钥,无需先安装独立密码库服务。每个账号独立存储,密文位于夭夭数据目录的 `credential-vault/`;单实例锁防止其他 broker 同时写入。默认锁定,重启不自动解锁。它不向 Bot 提供秘密读取、任务租约或自动登录执行。
 
 配置 `HERMES_YAOYAO_VAULT_SOCKET` / `HERMES_YAOYAO_VAULT_TOKEN_FILE` 后,仍使用原有独立密码库服务;显式配置的服务不可用时保持离线,不切换到新的本机保险箱。独立服务可以部署在 Hermes 执行节点,并在隔离部署验收后启用受保护执行器。密码库不是 Hermes 的环境变量或工具配置。
 
@@ -22,7 +22,7 @@
 
 网站仅支持用户在专用 UI 配置的主页面标准 HTML POST 表单:固定 HTTPS origin、登录/提交/成功页路径、form id、输入 name 和成功标记,允许成功跳转也必须预先指定。每个授权使用独立无持久资料的 Playwright Chromium/context,经私有 pipe 控制,无公开 CDP、截图、DOM/请求/Cookie 工具;受保护阶段不交给 Bot 通用浏览器。跨 origin、iframe、popup、SSO、MFA、验证码、XHR 登录或不匹配表单转人工接管,不会猜测或通用 fill/JS 降级。销毁本地 Cookie/context;可配置同 origin 固定 GET 退出路径,在正常完成时执行。未配置退出或中途取消时不能保证远端会话撤销,需站点自身过期/人工核对。登录成功回执只是认证证明,本期没有提供登录后任意网站浏览/业务操作;不能将受保护 Cookie 导出给通用工具。浏览器路由检查不能代替 OS 网络策略(例如 WebRTC/后台网络),也不能代替独立身份/禁止调试。
 
-SSH/SFTP 使用固定 `ssh2` 1.17.0,公钥认证及用户确认的 SHA256 host key,未知 host key 拒绝且不会尝试密码认证。SSH 只执行预先配置的绝对可执行文件,无参数/插值/PTY/环境/Agent forwarding/代理/端口转发,返回退出码,不返回 stdout/stderr。服务器协议内部执行方式由远端 SSH 服务决定,这不等于任意远端程序的沙箱。SFTP 只读取指定普通文件并返回有界字节数/校验,或把专用 UI 输入且加密的固定正文以 0600/exclusive-create 写到批准的绝对新路径;不覆盖已存在文件,不允许 Bot 提供正文/路径。限制为读至多 1 MiB、写至多 64 KiB,旧空正文编辑保留,不回显。没有将任意服务器上的实时文件/符号链接竞态转化为强隔离;远端目录仍必须由可信账号控制。执行断开/取消会关闭短连接,远端命令可能已执行、写入可能已部分落盘,不能自动重试或自动删除结果。明文 FTP 不支持。
+SSH/SFTP 使用固定 `ssh2` 1.17.0,支持专用界面选择密码登录或私钥登录;旧凭据未标记认证方式时仍使用私钥。`target.auth` 可为 `password` / `privateKey`,省略等同于私钥。密码与私钥均按主密码加密保存,编辑时留空保留原值;切换认证方式必须填写新的秘密,切换到密码登录会清除旧私钥口令。执行只尝试所选认证方式,不自动回退,也不启用 keyboard-interactive/MFA。连接仍要求用户确认的 SHA256 host key,指纹不符会在任何认证前拒绝。SSH 只执行预先配置的绝对可执行文件,无参数/插值/PTY/环境/Agent forwarding/代理/端口转发,返回退出码,不返回 stdout/stderr。服务器协议内部执行方式由远端 SSH 服务决定,这不等于任意远端程序的沙箱。SFTP 只读取指定普通文件并返回有界字节数/校验,或把专用 UI 输入且加密的固定正文以 0600/exclusive-create 写到批准的绝对新路径;不覆盖已存在文件,不允许 Bot 提供正文/路径。限制为读至多 1 MiB、写至多 64 KiB,旧空正文编辑保留,不回显。没有将任意服务器上的实时文件/符号链接竞态转化为强隔离;远端目录仍必须由可信账号控制。执行断开/取消会关闭短连接,远端命令可能已执行、写入可能已部分落盘,不能自动重试或自动删除结果。明文 FTP 不支持。
 
 开发仅使用临时目录中的 dummy 凭据和本机测试夹具。真实秘密录入、真实登录、OS 用户/权限/守护进程安装及生产切换都不属于本轮本地实现操作。
 

+ 16 - 9
src/client/components/app/CredentialVaultPanel.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { onMounted, onBeforeUnmount, ref } from 'vue'
+import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
 import { apiRequest } from '@/api/client'
 import type { JsonValue } from '@shared/types'
 import type { CredentialSummary, CredentialVaultStatus, CredentialOperation } from '@shared/credentialVault'
@@ -12,6 +12,9 @@ const root='/api/app/vault', state=ref<View>(), busy=ref(false), error=ref(''),
 const master=ref(''), confirmation=ref(''), replacement=ref(''), secret=ref(''), passphrase=ref('')
 const name=ref(''), username=ref(''), kind=ref<'website'|'ssh'>('website'), origin=ref(''), host=ref(''), port=ref(22), hostKey=ref('')
 const editing=ref<CredentialSummary>(), showEditor=ref(false), taskId=ref(''), credentialId=ref(''), operation=ref<CredentialOperation>('website.login')
+const sshAuth=ref<'privateKey'|'password'>('privateKey')
+const authenticationChanged=computed(()=>editing.value?.target.kind==='ssh'&&kind.value==='ssh'&&sshAuth.value!==(editing.value.target.auth??'privateKey'))
+const secretRequired=computed(()=>!editing.value||authenticationChanged.value)
 const configureUse=ref(false),useKind=ref<'ssh.exec'|'sftp.read'|'sftp.write'>('ssh.exec'),command=ref('/usr/bin/uptime'),remotePath=ref(''),contents=ref(''),maxBytes=ref(65536),clearPassphrase=ref(false)
 const loginPath=ref('/login'),submitPath=ref('/login'),successPath=ref('/account'),logoutPath=ref(''),formId=ref('login'),usernameName=ref('username'),passwordName=ref('password'),successSelector=ref('#login-success')
 let timer:ReturnType<typeof setInterval>|undefined,alive=true
@@ -19,7 +22,7 @@ const inflight=new Set<AbortController>()
 function clearSecrets(){master.value='';confirmation.value='';replacement.value='';secret.value='';passphrase.value='';contents.value='';clearPassphrase.value=false}
 function failure(cause:unknown){
   const code=cause&&typeof cause==='object'&&'code' in cause?String(cause.code):''
-  return ({vault_offline:'密码保险箱连接不可用,请刷新状态或检查数据目录权限。',vault_locked:'密码保险箱已锁定,请重新手动解锁后添加凭据。',vault_unlock_failed:'主密码错误或密码库已损坏,请核对主密码。',vault_invalid_request:'填写内容不符合要求:主密码至少 12 位;网站须为不含路径的 HTTPS 地址;SSH 须提供 SHA256 主机指纹。',vault_executor_not_enabled:'本机保险箱可以保存凭据;Bot 自动使用需要连接独立密码库服务。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
+  return ({vault_offline:'密码保险箱连接不可用,请刷新状态或检查数据目录权限。',vault_locked:'密码保险箱已锁定,请重新手动解锁后添加凭据。',vault_unlock_failed:'主密码错误或密码库已损坏,请核对主密码。',vault_invalid_request:'填写内容不符合要求:主密码至少 12 位;网站须为不含路径的 HTTPS 地址;SSH 须提供 SHA256 主机指纹。',vault_auth_secret_required:'更换 SSH 认证方式后,请重新填写密码或私钥。',vault_executor_not_enabled:'本机保险箱可以保存凭据;Bot 自动使用需要连接独立密码库服务。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
 }
 async function request<T>(path:string,method:'GET'|'POST'|'PUT'|'DELETE'='GET',body?:Record<string,JsonValue>){
   const c=new AbortController();inflight.add(c)
@@ -40,6 +43,7 @@ function edit(entry?:CredentialSummary){
   clearSecrets();editing.value=entry;name.value=entry?.name??'';username.value=entry?.username??'';kind.value=entry?.target.kind??'website'
   origin.value=entry?.target.kind==='website'?entry.target.origin:'';host.value=entry?.target.kind==='ssh'?entry.target.host:''
   port.value=entry?.target.kind==='ssh'?entry.target.port:22;hostKey.value=entry?.target.kind==='ssh'?entry.target.hostKey:''
+  sshAuth.value=entry?.target.kind==='ssh'?entry.target.auth??'privateKey':'privateKey'
   configureUse.value=!!entry?.usage;useKind.value=entry?.usage&&entry.usage.kind!=='website.form'?entry.usage.kind:'ssh.exec'
   command.value=entry?.usage?.kind==='ssh.exec'?entry.usage.command:'/usr/bin/uptime';remotePath.value=entry?.usage&&(entry.usage.kind==='sftp.read'||entry.usage.kind==='sftp.write')?entry.usage.remotePath:''
   maxBytes.value=entry?.usage?.kind==='sftp.read'?entry.usage.maxBytes:65536
@@ -47,10 +51,12 @@ function edit(entry?:CredentialSummary){
   loginPath.value=p?.loginPath??'/login';submitPath.value=p?.submitPath??'/login';successPath.value=p?.successPath??'/account';logoutPath.value=p?.logoutPath??'';formId.value=p?.formId??'login';usernameName.value=p?.usernameName??'username';passwordName.value=p?.passwordName??'password';successSelector.value=p?.successSelector??'#login-success'
   showEditor.value=true
 }
+function changeSshAuth(){secret.value='';passphrase.value='';clearPassphrase.value=false}
 async function save(){
-  const target:Record<string,JsonValue>=kind.value==='website'?{kind:'website',origin:origin.value}:{kind:'ssh',host:host.value,port:port.value,hostKey:hostKey.value}
+  if(secretRequired.value&&!secret.value){error.value=authenticationChanged.value?'更换 SSH 认证方式后,请重新填写密码或私钥。':'请填写密码或私钥。';return}
+  const target:Record<string,JsonValue>=kind.value==='website'?{kind:'website',origin:origin.value}:{kind:'ssh',host:host.value,port:port.value,hostKey:hostKey.value,...(sshAuth.value==='password'?{auth:'password'}:{})}
   const usage:Record<string,JsonValue>|undefined=!configureUse.value?undefined:kind.value==='website'?{kind:'website.form',loginPath:loginPath.value,submitPath:submitPath.value,successPath:successPath.value,...(logoutPath.value?{logoutPath:logoutPath.value}:{}),formId:formId.value,usernameName:usernameName.value,passwordName:passwordName.value,successSelector:successSelector.value}:useKind.value==='ssh.exec'?{kind:'ssh.exec',command:command.value}:useKind.value==='sftp.read'?{kind:'sftp.read',remotePath:remotePath.value,maxBytes:maxBytes.value}:{kind:'sftp.write',remotePath:remotePath.value,...(contents.value||!editing.value||editing.value.usage?.kind!=='sftp.write'?{contents:contents.value}:{})}
-  const body={name:name.value,username:username.value,target,...(usage?{usage}:{}),...(secret.value?{secret:secret.value}:{}),...(clearPassphrase.value?{passphrase:''}:passphrase.value?{passphrase:passphrase.value}:{}),...(editing.value?{revision:editing.value.revision}:{})}
+  const body={name:name.value,username:username.value,target,...(usage?{usage}:{}),...(secret.value?{secret:secret.value}:{}),...(kind.value==='ssh'&&sshAuth.value==='privateKey'?(clearPassphrase.value?{passphrase:''}:passphrase.value?{passphrase:passphrase.value}:{}):{}),...(editing.value?{revision:editing.value.revision}:{})}
   await action(async()=>{await request(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body);showEditor.value=false},'凭据已加密保存,密码与私钥不回显')
 }
 async function remove(entry:CredentialSummary){await action(async()=>{await request('/entries/'+entry.id,'DELETE')},'凭据已删除,相关任务授权已撤销')}
@@ -114,16 +120,17 @@ onBeforeUnmount(()=>{
       </form>
       <template v-else>
         <div class="actions"><button type="button" :disabled="busy" @click="lock">立即锁定并撤销授权</button><button type="button" :disabled="busy" @click="edit()">添加凭据</button><button type="button" :disabled="busy" @click="backup">导出加密备份</button></div>
-        <ul><li v-for="entry in state.entries" :key="entry.id"><strong>{{entry.name}}</strong><p>{{entry.username}} · {{entry.target.kind==='website'?entry.target.origin:entry.target.host+':'+entry.target.port}}</p><code>{{entry.id}}</code><div class="actions"><button type="button" :disabled="busy" @click="edit(entry)">编辑</button><button type="button" :disabled="busy" @click="remove(entry)">删除并撤权</button></div></li></ul>
+        <ul><li v-for="entry in state.entries" :key="entry.id"><strong>{{entry.name}}</strong><p>{{entry.username}} · {{entry.target.kind==='website'?entry.target.origin:entry.target.host+':'+entry.target.port}}<span v-if="entry.target.kind==='ssh'"> · {{entry.target.auth==='password'?'密码登录':'私钥登录'}}</span></p><code>{{entry.id}}</code><div class="actions"><button type="button" :disabled="busy" @click="edit(entry)">编辑</button><button type="button" :disabled="busy" @click="remove(entry)">删除并撤权</button></div></li></ul>
         <form v-if="showEditor" @submit.prevent="save">
           <label>名称<input v-model="name" maxlength="120" required :disabled="busy" /></label>
           <label>用户名<input v-model="username" maxlength="256" autocomplete="off" required :disabled="busy" /></label>
-          <label>用途<select v-model="kind" :disabled="busy||!!editing"><option value="website">HTTPS 网站</option><option value="ssh">SSH 密钥 / SFTP</option></select></label>
+          <label>用途<select v-model="kind" :disabled="busy||!!editing" @change="changeSshAuth"><option value="website">HTTPS 网站</option><option value="ssh">SSH / SFTP</option></select></label>
           <label v-if="kind==='website'">准确网站 origin<input v-model="origin" type="url" placeholder="https://example.com" required :disabled="busy" /></label>
           <template v-else><label>SSH 主机<input v-model="host" required :disabled="busy" /></label><label>端口<input v-model.number="port" type="number" min="1" max="65535" required :disabled="busy" /></label><label>已人工确认的 host key 指纹<input v-model="hostKey" placeholder="SHA256:…" required :disabled="busy" /></label></template>
-          <label>{{kind==='website'?'密码':'私钥'}}<input v-if="kind==='website'" v-model="secret" type="password" autocomplete="new-password" :required="!editing" maxlength="32768" :disabled="busy" /><textarea v-else v-model="secret" autocomplete="off" :required="!editing" maxlength="32768" :disabled="busy" /><small v-if="editing">留空保留原值;没有秘密读取或回显接口。</small></label>
-          <label v-if="kind==='ssh'">私钥口令(可选)<input v-model="passphrase" type="password" autocomplete="new-password" :disabled="busy" /></label>
-          <label v-if="kind==='ssh'&&editing"><input v-model="clearPassphrase" type="checkbox" :disabled="busy" />清除原私钥口令(留空输入默认保留)</label>
+          <label v-if="kind==='ssh'">SSH 认证方式<select v-model="sshAuth" :disabled="busy" @change="changeSshAuth"><option value="privateKey">私钥登录</option><option value="password">密码登录</option></select></label>
+          <label>{{kind==='website'?'密码':sshAuth==='password'?'SSH 登录密码':'私钥'}}<input v-if="kind==='website'||sshAuth==='password'" v-model="secret" type="password" autocomplete="new-password" :required="secretRequired" maxlength="32768" :disabled="busy" /><textarea v-else v-model="secret" autocomplete="off" :required="secretRequired" maxlength="32768" :disabled="busy" /><small v-if="authenticationChanged">认证方式已更换,需要填写新的{{sshAuth==='password'?'登录密码':'私钥'}}。</small><small v-else-if="editing">留空保留原值;没有秘密读取或回显接口。</small></label>
+          <label v-if="kind==='ssh'&&sshAuth==='privateKey'">私钥口令(可选)<input v-model="passphrase" type="password" autocomplete="new-password" :disabled="busy" /></label>
+          <label v-if="kind==='ssh'&&sshAuth==='privateKey'&&editing"><input v-model="clearPassphrase" type="checkbox" :disabled="busy" />清除原私钥口令(留空输入默认保留)</label>
           <label v-if="state.storageMode!=='local'"><input v-model="configureUse" type="checkbox" :disabled="busy||!!editing?.usage" />配置允许 Bot 执行的固定操作</label>
           <template v-if="configureUse&&kind==='website'">
             <p>仅支持主页面的标准 POST 表单。允许跳转仅限此 origin 的成功页;iframe、跨站 SSO、MFA 或验证码转人工。</p>

+ 9 - 4
src/server/credentialVault/schema.ts

@@ -8,7 +8,8 @@ export const website = z.object({ kind: z.literal('website'), origin: z.string()
   } catch { ctx.addIssue({ code: 'custom', message: '网站需要准确的 HTTPS origin' }); return z.NEVER }
 }) }).strict()
 export const ssh = z.object({ kind: z.literal('ssh'), host: z.string().min(1).max(253).regex(/^[A-Za-z0-9.:[\]_-]+$/).transform(v => v.toLowerCase()),
-  port: z.number().int().min(1).max(65535), hostKey: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}=?$/) }).strict()
+  port: z.number().int().min(1).max(65535), hostKey: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}=?$/),
+  auth: z.enum(['privateKey', 'password']).optional() }).strict()
 export const target = z.discriminatedUnion('kind', [website, ssh])
 const path = z.string().min(2).max(1024).refine(v=>v.startsWith('/')&&!v.includes('\\')&&!/[\u0000-\u001f\u007f?#]/.test(v)&&v.split('/').every((s,i)=>i===0||!!s&&s!=='.'&&s!=='..'))
 const name = z.string().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_.:-]*$/)
@@ -26,10 +27,14 @@ export const leaseInput = z.object({
   seconds: z.number().int().min(15).max(120).default(60),
 }).strict()
 export type LeaseInput = z.infer<typeof leaseInput>
-export const entryInput = z.object({ name: z.string().trim().min(1).max(120), username: z.string().min(1).max(256),
+const entryFields = z.object({ name: z.string().trim().min(1).max(120), username: z.string().min(1).max(256),
   target, usage:usage.optional(), secret: z.string().min(1).max(32768), passphrase: z.string().max(4096).optional() }).strict()
-export const updateInput = entryInput.extend({ secret: entryInput.shape.secret.optional(), usage:updatedUsage.optional(), revision: z.number().int().positive() })
-export const storedEntry = entryInput.extend({ id: z.string().uuid(), revision: z.number().int().positive(), updatedAt: z.number().int().nonnegative() })
+const validPassphrase = (entry: { target: z.infer<typeof target>; passphrase?: string }) =>
+  entry.target.kind !== 'ssh' || entry.target.auth !== 'password' || !entry.passphrase
+const passphraseError = { message: 'SSH 密码登录不使用私钥口令', path: ['passphrase'] }
+export const entryInput = entryFields.refine(validPassphrase, passphraseError)
+export const updateInput = entryFields.extend({ secret: entryFields.shape.secret.optional(), usage:updatedUsage.optional(), revision: z.number().int().positive() }).refine(validPassphrase, passphraseError)
+export const storedEntry = entryFields.extend({ id: z.string().uuid(), revision: z.number().int().positive(), updatedAt: z.number().int().nonnegative() }).refine(validPassphrase, passphraseError)
 export type VaultEntry = z.infer<typeof storedEntry>
 export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
   const result = schema.safeParse(value)

+ 4 - 3
src/server/credentialVault/sshExecutor.ts

@@ -4,7 +4,7 @@ import type {IsolationGate,CredentialExecutionReceipt} from './executor.js'
 import type {VaultEntry,LeaseInput} from './schema.js'
 import {HttpError} from '../errors.js'
 const call=<T>(start:(done:(error:Error|undefined|null,value:T)=>void)=>void)=>new Promise<T>((resolve,reject)=>start((error,value)=>error?reject(error):resolve(value)))
-/** Key-only, pinned-host SSH. No shell session, command interpolation, PTY,
+/** Explicit password or private-key authentication with a pinned host. No shell session, command interpolation, PTY,
  * environment, agent forwarding, ProxyCommand, jump host or TCP forwarding. */
 export class SshSftpExecutor {
   constructor(private gate:IsolationGate){}
@@ -29,8 +29,9 @@ export class SshSftpExecutor {
     conn.once('ready',ready)
     const check=()=>{signal.throwIfAborted();this.gate.assert()}
     try{
-      conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:entry.username,privateKey:entry.secret,...(entry.passphrase?{passphrase:entry.passphrase}:{}),
-        authHandler:['publickey'],tryKeyboard:false,agentForward:false,readyTimeout:5000,keepaliveInterval:0,
+      conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:entry.username,
+        ...(host.auth === 'password' ? {password:entry.secret} : {privateKey:entry.secret,...(entry.passphrase?{passphrase:entry.passphrase}:{})}),
+        authHandler:[host.auth === 'password' ? 'password' : 'publickey'],tryKeyboard:false,agentForward:false,readyTimeout:5000,keepaliveInterval:0,
         algorithms:{serverHostKey:['ssh-ed25519','rsa-sha2-512','rsa-sha2-256','ecdsa-sha2-nistp256']},
         hostVerifier:(key:Buffer)=>{const digest=createHash('sha256').update(key).digest();const match=timingSafeEqual(digest,expected);hostRejected=!match;return match}})
       const work=(async()=>{

+ 6 - 1
src/server/credentialVault/store.ts

@@ -169,8 +169,13 @@ export class CredentialVaultStore {
     const s = this.active(owner), input = parse(updateInput, value), old = s.entries.find(v => v.id === id)
     if (!old) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
     if (old.revision !== input.revision) throw new HttpError(409, '凭据已变化,请刷新', 'vault_revision_conflict')
+    const changedAuthentication = old.target.kind !== input.target.kind || old.target.kind === 'ssh' && input.target.kind === 'ssh'
+      && (old.target.auth ?? 'privateKey') !== (input.target.auth ?? 'privateKey')
+    if (changedAuthentication && !input.secret) throw new HttpError(400, '更换认证方式需要重新填写密码或私钥', 'vault_auth_secret_required')
     const changedUsage=input.usage?.kind==='sftp.write'?{...input.usage,contents:input.usage.contents??(old.usage?.kind==='sftp.write'?old.usage.contents:undefined)}:input.usage??old.usage
-    const next = parse(storedEntry,{ ...old, ...input,usage:changedUsage, secret: input.secret ?? old.secret, revision: old.revision + 1, updatedAt: this.now() })
+    const next = parse(storedEntry,{ ...old, ...input,usage:changedUsage, secret: input.secret ?? old.secret,
+      passphrase: input.target.kind === 'ssh' && input.target.auth === 'password' ? undefined : input.passphrase ?? old.passphrase,
+      revision: old.revision + 1, updatedAt: this.now() })
     this.commit(owner, s.entries.map(v => v.id === id ? next : v)); this.onLock(owner)
     return summary(next)
   }

+ 1 - 1
src/shared/credentialVault.ts

@@ -2,7 +2,7 @@
 export const CREDENTIAL_VAULT_PROTOCOL = 1
 export type CredentialTarget =
   | { kind: 'website'; origin: string }
-  | { kind: 'ssh'; host: string; port: number; hostKey: string }
+  | { kind: 'ssh'; host: string; port: number; hostKey: string; auth?: 'privateKey' | 'password' }
 export type CredentialOperation = 'website.login' | 'ssh.exec' | 'sftp.read' | 'sftp.write'
 export type CredentialUseSummary =
   | { kind:'website.form';loginPath:string;submitPath:string;successPath:string;logoutPath?:string;formId:string;usernameName:string;passwordName:string;successSelector:string }

+ 42 - 1
tests/client/credentialVaultPanel.test.ts

@@ -78,7 +78,7 @@ it('configures an encrypted fixed SFTP body in the dedicated UI, clears it after
   await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
   const editor=w.findAll('form')[0]!
   await editor.get('select').setValue('ssh');await editor.get('input[type=checkbox]').setValue(true)
-  await editor.findAll('select')[1]!.setValue('sftp.write')
+  await editor.findAll('label').find(l=>l.text().startsWith('允许操作'))!.get('select').setValue('sftp.write')
   const label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
   await label('名称').get('input').setValue('dummy transfer');await label('用户名').get('input').setValue('dummy')
   await label('SSH 主机').get('input').setValue('127.0.0.1');await label('已人工确认').get('input').setValue('SHA256:'+'A'.repeat(43))
@@ -88,3 +88,44 @@ it('configures an encrypted fixed SFTP body in the dedicated UI, clears it after
   expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:expect.objectContaining({usage:{kind:'sftp.write',remotePath:'/approved/new.txt',contents:'DUMMY-private-write-body'}})}))
   expect(w.find('textarea').exists()).toBe(false);expect(w.text()).not.toContain('DUMMY-private-write-body')
 })
+
+it('saves an SSH login password through a masked field without a private key passphrase or Bot lease in local mode',async()=>{
+  state.unlocked=true;state.storageMode='local';const w=await setup()
+  await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
+  const editor=w.get('form'),label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
+  await label('用途').get('select').setValue('ssh');await label('SSH 认证方式').get('select').setValue('password')
+  await label('名称').get('input').setValue('Dummy password SSH');await label('用户名').get('input').setValue('dummy')
+  await label('SSH 主机').get('input').setValue('ssh.example.test');await label('已人工确认').get('input').setValue('SHA256:'+'A'.repeat(43))
+  const field=label('SSH 登录密码').get<HTMLInputElement>('input');expect(field.attributes('type')).toBe('password')
+  await field.setValue('DUMMY-ssh-login-password')
+  expect(editor.text()).not.toContain('私钥口令');expect(editor.find('textarea').exists()).toBe(false)
+  await editor.trigger('submit');await flushPromises()
+  expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:{name:'Dummy password SSH',username:'dummy',target:{kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43),auth:'password'},secret:'DUMMY-ssh-login-password'}}))
+  expect(w.text()).not.toContain('DUMMY-ssh-login-password');expect(w.text()).not.toContain('授予本任务')
+})
+
+it('clears SSH secret drafts on authentication changes and requires a new secret when editing a legacy key entry',async()=>{
+  state.unlocked=true;state.storageMode='local';state.entries=[{id:'legacy-id',name:'legacy SSH',username:'dummy',target:{kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43)},revision:2,updatedAt:1}]
+  const w=await setup();await w.findAll('button').find(b=>b.text()==='编辑')!.trigger('click')
+  const editor=w.get('form'),label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
+  expect(label('SSH 认证方式').get('select').element.value).toBe('privateKey')
+  await label('私钥').get('textarea').setValue('DUMMY-private-key-draft');await label('私钥口令').get('input').setValue('DUMMY-passphrase-draft')
+  await label('SSH 认证方式').get('select').setValue('password')
+  const field=label('SSH 登录密码').get<HTMLInputElement>('input')
+  expect(field.element.value).toBe('');expect(field.attributes('required')).toBeDefined()
+  await editor.trigger('submit');await flushPromises()
+  expect(w.get('[role=alert]').text()).toContain('重新填写');expect(api.mock.calls.some(([,o])=>o?.method==='PUT')).toBe(false)
+  await field.setValue('DUMMY-replacement-password');await editor.trigger('submit');await flushPromises()
+  const body=api.mock.calls.find(([p,o])=>p.endsWith('/legacy-id')&&o.method==='PUT')![1].body
+  expect(body).toMatchObject({target:{auth:'password'},secret:'DUMMY-replacement-password',revision:2});expect(body).not.toHaveProperty('passphrase')
+  expect(JSON.stringify(body)).not.toContain('DUMMY-private-key-draft')
+})
+
+it('keeps an existing SSH password when its masked replacement field is left blank',async()=>{
+  state.unlocked=true;state.storageMode='local';state.entries=[{id:'password-id',name:'password SSH',username:'dummy',target:{kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43),auth:'password'},revision:3,updatedAt:1}]
+  const w=await setup();await w.findAll('button').find(b=>b.text()==='编辑')!.trigger('click')
+  const field=w.get<HTMLInputElement>('input[type=password]');expect(field.element.value).toBe('');expect(field.attributes('required')).toBeUndefined()
+  await w.get('form').trigger('submit');await flushPromises()
+  const body=api.mock.calls.find(([p,o])=>p.endsWith('/password-id')&&o.method==='PUT')![1].body
+  expect(body.target.auth).toBe('password');expect(body).not.toHaveProperty('secret');expect(body).not.toHaveProperty('passphrase')
+})

+ 21 - 0
tests/e2e/credential-vault.spec.ts

@@ -29,6 +29,27 @@ test('a default installation creates and saves encrypted passwords through the s
   await expect(panel.getByRole('status').filter({hasText:'凭据已加密保存'})).toBeVisible()
   await expect(panel.getByRole('button',{name:'授予本任务 60 秒引用权限'})).toHaveCount(0)
   expect(await panel.textContent()).not.toContain(secret)
+  await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
+  await panel.getByRole('combobox',{name:/^用途/}).selectOption('ssh')
+  await panel.getByRole('combobox',{name:/^SSH 认证方式/}).selectOption('password')
+  await panel.getByLabel('名称',{exact:true}).fill('浏览器测试 SSH 密码')
+  await panel.getByLabel('用户名',{exact:true}).fill('dummy-ssh-user')
+  await panel.getByLabel('SSH 主机',{exact:true}).fill('ssh.example.test')
+  await panel.getByLabel('已人工确认的 host key 指纹',{exact:true}).fill('SHA256:'+'A'.repeat(43))
+  const sshPassword=panel.getByLabel(/^SSH 登录密码/)
+  await expect(sshPassword).toHaveAttribute('type','password')
+  await sshPassword.fill('DUMMY-browser-ssh-password')
+  await expect(panel.getByLabel('私钥口令(可选)',{exact:true})).toHaveCount(0)
+  await page.screenshot({path:evidence+'/ssh-password-editor.png'})
+  await panel.getByRole('button',{name:'加密保存',exact:true}).click()
+  const sshEntry=panel.locator('li').filter({hasText:'浏览器测试 SSH 密码'})
+  await expect(sshEntry).toContainText('密码登录')
+  expect(await panel.textContent()).not.toContain('DUMMY-browser-ssh-password')
+  await sshEntry.getByRole('button',{name:'编辑',exact:true}).click()
+  await expect(sshPassword).toHaveValue('')
+  await expect(panel.getByRole('combobox',{name:/^SSH 认证方式/})).toHaveValue('password')
+  await panel.getByRole('button',{name:'加密保存',exact:true}).click()
+  await expect(sshEntry).toContainText('密码登录')
   await page.screenshot({path:evidence+'/desktop-saved.png'})
   await page.setViewportSize({width:375,height:812})
   await expect(panel.getByRole('button',{name:'添加凭据',exact:true})).toBeVisible()

+ 9 - 4
tests/fixtures/credentialProtocols.ts

@@ -32,16 +32,21 @@ export async function websiteFixture(home:string){
     usage:{kind:'website.form' as const,loginPath:'/login',submitPath:'/login',successPath:'/account',logoutPath:'/logout',formId:'login',usernameName:'username',passwordName:'password',successSelector:'#login-success'},
     close:async()=>{server.closeAllConnections();await new Promise<void>(r=>server.close(()=>r()))}}
 }
-export async function sshFixture(){
+export async function sshFixture(auth: 'privateKey' | 'password' = 'privateKey'){
   const host=utils.generateKeyPairSync('ed25519'),key=utils.generateKeyPairSync('ed25519'),parsed=utils.parseKey(key.public) as utils.ParsedKey
   const fingerprint='SHA256:'+createHash('sha256').update((utils.parseKey(host.private) as utils.ParsedKey).getPublicSSH()).digest('base64').replace(/=+$/,'')
   const clients=new Set<Connection>(),files=new Map<string,Buffer>([['/approved/input.txt',Buffer.from('DUMMY-private-file-contents')]])
   const executed=Promise.withResolvers<void>(),written=Promise.withResolvers<void>()
-  const state={auths:0,commands:[] as string[],opens:[] as {path:string,flags:number,mode:number}[],writes:0,delayWrite:false,delayExec:false,readRedirect:false}
+  const password='DUMMY-ssh-login-password-only'
+  const state={auths:0,authMethods:[] as string[],commands:[] as string[],opens:[] as {path:string,flags:number,mode:number}[],writes:0,delayWrite:false,delayExec:false,readRedirect:false}
   const server=new Server({hostKeys:[host.private]},client=>{
     clients.add(client);client.on('error',()=>{});client.on('close',()=>clients.delete(client))
     client.on('authentication',ctx=>{
-      if(ctx.method!=='publickey'||ctx.username!=='dummy-ssh-user'||!ctx.key.data.equals(parsed.getPublicSSH())||ctx.signature&&parsed.verify(ctx.blob!,ctx.signature)!==true){ctx.reject();return}
+      state.authMethods.push(ctx.method)
+      const valid=ctx.username==='dummy-ssh-user'&&(auth==='password'
+        ? ctx.method==='password'&&ctx.password===password
+        : ctx.method==='publickey'&&ctx.key.data.equals(parsed.getPublicSSH())&&(!ctx.signature||parsed.verify(ctx.blob!,ctx.signature)===true))
+      if(!valid){ctx.reject([auth==='password'?'password':'publickey']);return}
       state.auths++;ctx.accept()
     }).on('ready',()=>client.on('session',accept=>{
       const session=accept()
@@ -70,6 +75,6 @@ export async function sshFixture(){
     }))
   })
   server.listen(0,'127.0.0.1');await once(server,'listening')
-  return {state,files,executed:executed.promise,written:written.promise,username:'dummy-ssh-user',secret:key.private,target:{kind:'ssh' as const,host:'127.0.0.1',port:(server.address() as any).port,hostKey:fingerprint},
+  return {state,files,executed:executed.promise,written:written.promise,username:'dummy-ssh-user',secret:auth==='password'?password:key.private,target:{kind:'ssh' as const,host:'127.0.0.1',port:(server.address() as any).port,hostKey:fingerprint,...(auth==='password'?{auth}:{})},
     close:async()=>{for(const client of clients)client.end();await new Promise<void>(r=>server.close(()=>r()))}}
 }

+ 10 - 4
tests/server/credentialVaultAdapters.test.ts

@@ -42,8 +42,8 @@ it('cancelling a submitted website login closes its private context and leaves a
   await vi.waitFor(()=>expect(site.state.logins).toBe(1));controller.abort();await result
   expect(site.state.logins).toBe(1);expect(site.state.logouts).toBe(0)
 },15000)
-it('uses pinned public-key SSH, a fixed executable and bounded exact SFTP paths; receipts omit stdout and file contents',async()=>{
-  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
+it.each(['privateKey','password'] as const)('uses pinned %s SSH, a fixed executable and bounded exact SFTP paths; receipts omit stdout and file contents',async auth=>{
+  const h=home(),ssh=await sshFixture(auth);cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
   const base={name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target}
   const command=entry({...base,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
   expect(await executor.execute(command,input(command,'ssh.exec'),new AbortController().signal)).toEqual({status:'complete',operation:'ssh.exec',exitCode:0})
@@ -57,8 +57,8 @@ it('uses pinned public-key SSH, a fixed executable and bounded exact SFTP paths;
   expect(ssh.state.opens.at(-1)).toMatchObject({flags:58,mode:0o600})
   await expect(executor.execute(write,input(write,'sftp.write'),new AbortController().signal)).rejects.toThrow() // Never overwrite.
 },15000)
-it('rejects an unknown SSH host key before authentication, symlink targets and oversized files',async()=>{
-  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
+it.each(['privateKey','password'] as const)('rejects an unknown SSH host key before %s authentication, symlink targets and oversized files',async auth=>{
+  const h=home(),ssh=await sshFixture(auth);cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
   const base={name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target},e=entry({...base,target:{...ssh.target,hostKey:'SHA256:'+'A'.repeat(43)},usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
   await expect(executor.execute(e,input(e,'ssh.exec'),new AbortController().signal)).rejects.toMatchObject({code:'vault_host_key_rejected'});expect(ssh.state.auths).toBe(0)
   const read=entry({...base,usage:{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:1}})
@@ -73,6 +73,12 @@ it('a malformed private key produces a rejected operation without unhandled conn
   await expect(new ProtectedCredentialExecutor(join(h,'executor'),gate).execute(e,input(e,'ssh.exec'),new AbortController().signal)).rejects.toThrow()
   expect(ssh.state.auths).toBe(0)
 })
+it('rejects a wrong SSH password without trying another authentication method or executing a command',async()=>{
+  const h=home(),ssh=await sshFixture('password');cleanup.push(ssh.close)
+  const e=entry({name:'dummy',username:ssh.username,secret:'DUMMY-wrong-ssh-password',target:ssh.target,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+  await expect(new ProtectedCredentialExecutor(join(h,'executor'),gate).execute(e,input(e,'ssh.exec'),new AbortController().signal)).rejects.toThrow()
+  expect(ssh.state.authMethods).toEqual(['password']);expect(ssh.state.auths).toBe(0);expect(ssh.state.commands).toEqual([])
+})
 it('cancelling SFTP during a lost write acknowledgement terminates promptly and leaves the partial result for manual inspection',async()=>{
   const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);ssh.state.delayWrite=true
   const e=entry({name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target,usage:{kind:'sftp.write',remotePath:'/approved/partial.txt',contents:'DUMMY-only'}}),controller=new AbortController()

+ 3 - 3
tests/server/credentialVaultBotFlow.test.ts

@@ -24,10 +24,10 @@ import { websiteFixture, sshFixture } from '../fixtures/credentialProtocols'
 import type { RunnerHub } from '../../src/server/runnerHub'
 import type { WorkspaceRun } from '../../src/shared/workspace'
 
-it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.cancel','ssh.exec.cancel','sftp.write.cancel'] as const)('a Bot turn uses its real tool catalog, task approval and protected adapter for local dummy %s, returning only a sanitized receipt',async scenario=>{
-  const cancelled=scenario.endsWith('.cancel'),operation=scenario.replace(/\.cancel$/,'') as 'website.login'|'ssh.exec'|'sftp.read'|'sftp.write'
+it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.cancel','ssh.exec.cancel','sftp.write.cancel','ssh.exec.password','sftp.read.password','sftp.write.password','ssh.exec.password.cancel'] as const)('a Bot turn uses its real tool catalog, task approval and protected adapter for local dummy %s, returning only a sanitized receipt',async scenario=>{
+  const cancelled=scenario.endsWith('.cancel'),operation=scenario.replace('.password','').replace(/\.cancel$/,'') as 'website.login'|'ssh.exec'|'sftp.read'|'sftp.write'
   const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-bot-flow-'))
-  const website=await websiteFixture(home),ssh=await sshFixture()
+  const website=await websiteFixture(home),ssh=await sshFixture(scenario.includes('.password')?'password':'privateKey')
   if(cancelled){website.state.delay=true;ssh.state.delayExec=operation==='ssh.exec';ssh.state.delayWrite=true}
   const isWebsite=operation==='website.login',master='DUMMY-master-password-only',secret=isWebsite?website.secret:ssh.secret,username=isWebsite?website.username:ssh.username,sessionCookie=website.cookie
   const usage=operation==='website.login'?website.usage:operation==='ssh.exec'?{kind:'ssh.exec',command:'/usr/bin/uptime'}:operation==='sftp.read'?{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:64}:{kind:'sftp.write',remotePath:'/approved/output.txt',contents:'DUMMY-fixed-transfer-body'}

+ 32 - 0
tests/server/credentialVaultStore.test.ts

@@ -81,3 +81,35 @@ it('encrypts fixed SFTP contents, never returns them, retains a blank edit and r
   for(const value of [{kind:'ssh.exec',command:'/bin/sh -c id'},{kind:'sftp.read',remotePath:'/approved/../outside',maxBytes:10}])expect(()=>store.update(owner,e.id,{...ssh,revision:2,usage:value})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
   expect(readFileSync(join(home,readdirSync(home).find(v=>v.endsWith('.vault'))!),'utf8')).not.toContain(body)
 })
+
+it('encrypts SSH passwords, preserves blank password edits and restores the authentication mode from a backup',async()=>{
+  const {store}=await fixture();store.initialize(owner,password);store.unlock(owner,password)
+  const login={name:'dummy ssh',username:'dummy',secret:'DUMMY-ssh-password',target:{kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43),auth:'password'}}
+  const e=store.add(owner,login)
+  expect(e.target).toMatchObject({auth:'password'});expect(e).not.toHaveProperty('secret');expect(e).not.toHaveProperty('passphrase')
+  store.update(owner,e.id,{...login,secret:undefined,revision:1})
+  expect(store.withSecret(owner,e.id,x=>x.secret)).toBe(login.secret)
+  const archive=store.backup(owner);expect(archive).not.toContain(login.secret)
+  const {store:restored}=await fixture();restored.restore(owner,archive,password);restored.unlock(owner,password)
+  expect(restored.list(owner)[0]!.target).toMatchObject({auth:'password'})
+  expect(restored.withSecret(owner,e.id,x=>x.secret)).toBe(login.secret)
+  expect(()=>store.add(owner,{...login,passphrase:'DUMMY-key-passphrase'})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
+  expect(()=>store.add(owner,{...login,target:{...login.target,auth:'keyboard-interactive'}})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
+})
+
+it('keeps old SSH entries as private keys and requires replacement secrets when changing authentication',async()=>{
+  const {store}=await fixture();store.initialize(owner,password);store.unlock(owner,password)
+  const key={name:'legacy ssh',username:'dummy',secret:'DUMMY-old-private-key',passphrase:'DUMMY-old-passphrase',target:{kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43)}}
+  const e=store.add(owner,key)
+  store.lock(owner);store.unlock(owner,password)
+  expect(store.list(owner)[0]!.target).not.toHaveProperty('auth')
+  const passwordTarget={...key.target,auth:'password'}
+  expect(()=>store.update(owner,e.id,{...key,passphrase:undefined,secret:undefined,target:passwordTarget,revision:1})).toThrowError(expect.objectContaining({code:'vault_auth_secret_required'}))
+  expect(store.withSecret(owner,e.id,x=>x.secret)).toBe(key.secret)
+  let revoked=0;store.onLock=()=>revoked++
+  store.update(owner,e.id,{...key,passphrase:undefined,secret:'DUMMY-new-ssh-password',target:passwordTarget,revision:1})
+  expect(store.withSecret(owner,e.id,x=>x.passphrase)).toBeUndefined();expect(revoked).toBe(1)
+  expect(()=>store.update(owner,e.id,{...key,secret:undefined,revision:2})).toThrowError(expect.objectContaining({code:'vault_auth_secret_required'}))
+  store.update(owner,e.id,{...key,revision:2})
+  expect(store.withSecret(owner,e.id,x=>x.secret)).toBe(key.secret);expect(revoked).toBe(2)
+})