跳转到内容

安全与合规

本页把散落在各处的安全设计集中成一页:每一条都给出可验证的实现细节(字段名、文件权限、算法、上限、判定顺序),并在最后一节如实列出当前实现里仍然存在的边界。

读者对象:要判断”能不能过我们安全评审”的管理员与运维,以及要审计实现的维护者。密钥与令牌的日常操作在部署与企业管控面,沙箱的操作细节在桌面客户端,应用运行时的边界在应用中心。

先把”谁不被信任”写清楚,后面的机制才有意义:

位置信任假设落地手段
员工机器不可信:同机同用户的其他进程、以及员工本人本地写面与会话面用持有性证明(只有本应用自己的页面才拿得到的 HttpOnly + SameSite=Strict cookie);打包版拒绝调试类命令行开关;窗口按 contextIsolation: true / nodeIntegration: false / sandbox: true 创建
客户端 ↔ 服务端客户端不可信,包括它自报的 Origin / Host员工令牌(90 天、只存哈希)+ 应用请求的持有性证明;应用请求的 host 只从 URL 路径段解析,绝不从请求头反解
服务端进程内受信任(这是我们自己的代码)但外部输入仍然被限制:应用 guest 只有 WASI 能力、有内存与时间硬上限
上游模型供应商只按管理员配置的地址出站(允许私网,云 metadata 与链路本地地址在拨号时一律拒)上游密钥只在服务端持有并加密落库;出站地址有独立护栏(见「出站与信任边界」)

一条贯穿全篇的取舍:默认拒绝,且失败方向要可解释。宁可拒绝一次合法操作并给出下一步,也不要静默降级——本仓多处代码注释都用「fail-closed」标注这一点(余额查询失败、授权读失败、沙箱不可用、写面证明缺席等)。

  • 上游 API Key、MFA 密钥、设置里的机密字段统一用 AES-GCM 加密后落库,密文前缀 enc:v1:,形态 enc:v1:<base64(nonce‖ciphertext)>,每次加密使用新的随机 nonce;
  • 主密钥来自环境变量 ZEOKEEL_MASTER_KEY,否则取数据根下的 master.key:32 字节随机、权限 0600、以 O_EXCL 独占创建(并发首启时只有一方写得成功,失败方重读)、数据目录权限 0700、绝不写入数据库;
  • 解不开的密文(换过主密钥、被篡改、外来值)一律返回错误,不会退化成”当成空值”。

master.key 丢了,库里已加密的上游密钥永久无法解密——这正是升级、备份与回滚把「备份必须包含数据目录且备份非空」写成铁律的原因。

应用打开与请求需要客户端出示持有性证明,服务端用 Ed25519 私钥签发。这把密钥启动期按部署生成、落在数据根(0600、原子写)、绝不编入镜像,与主密钥同一信任边界、同一备份口径。

凭据落盘位置与权限写法与边界
连接器凭据(含 OAuth 令牌、静态 token)<数据根>/users/<编码账号>/servers/<服务端哈希>/connectors/,目录 0700、文件 0600原子写(临时文件 + fsync + rename);目录必须是真实目录(符号链接直接拒);单文档 ≤ 64 KiB(写面与读面同一条上限);读失败分 unreadable / too-large / malformed 三类并 fail-closed——绝不把”读不出来”当成”没有凭据”,否则会把 access/refresh token 静默抹掉
员工会话令牌<数据根>/session.json,权限 0600有系统 keyring 时用 Electron safeStorage 加密;Linux 上没有 keyring 时回落 0600 明文并打一条告警(认账项,见最后一节)

连接器凭据按账号 × 服务端分域:换一台服务端不会被当成同一租户,旧布局下没有服务端标记的凭据不会被继承——代价是相关连接器需要重新授权一次(这是有意的 fail-closed 价格,另一条路是跨租户密钥重放)。

  • 服务端只在自身数据目录里读写;凡是用户输入参与拼路径的地方(归档名、版本号等)都会先过 SafePathSegment(非空、不含分隔符、不是 . / ..),并与名字白名单正则双重防护;
  • 应用运行时完全没有宿主文件系统能力:assets.read 的参数是包内逻辑路径,宿主在内存映射里做精确键查找(没有规范化、没有前缀匹配、没有通配);
  • 打包与技能归档一类需要遍历目录的地方,遍历根必须是真实目录(符号链接拒收),并把每次访问锚定到 realpath 后再比对前缀。
  • 随机 32 字节,只在库里存 SHA-256 哈希(明文只在签发那一刻返回给客户端一次),默认有效期 90 天;签发前顺带清扫过期行(有界,走过期索引);
  • 令牌是客户端访问 /api/client/v2/* 与 /v1/* 的唯一凭据;
  • 改密 / 降权 / 禁用在同一事务内更新用户行并 DELETE 该用户的全部 api_tokens 与 admin_sessions——权限变更与旧凭证失效原子生效,不留存量登录面。
项值
硬上限12 小时
空闲滑动60 分钟未使用即失效,每次校验成功刷新
存储库里只存 secret_hash,不存明文会话令牌
CSRF与会话同寿命的 HMAC token(HMAC(CSRFKey, "session:" + sessionID)),常量时间比对
鉴权链AdminAuth(会话 + CSRF)→ RequirePermission(权限点) → 业务 handler

管理员可选用 MFA(TOTP);重置密码后账号进入「下次登录必须改密」状态,服务端以 403 PASSWORD_CHANGE_REQUIRED 拦截其余接口。密码用 argon2id 哈希($argon2id$v=19$m=65536,t=3,p=2$…),最小长度默认 10 位(管理面可配 8–64)。

维度预算(默认)说明
账号10 次 / 5 分钟u:<用户名> 与 `ip
来源 IP60 次 / 5 分钟登录、OIDC 回调、OIDC 流程启动各有独立桶
计数口径只对失败计数「判定即记账」在同一临界区内完成(成功 = 清空该键,拒绝 = 不再重复记账)

三条容易做错的细节:

  • IP 必须按信任边界解析出的真实客户端 IP 计。反代部署下若用 TCP 对端地址,桶会坍缩成代理自己的 IP——一个未认证者用 60 次失败登录就能把全组织在 5 分钟窗口内锁在门外。这里采信 X-Forwarded-For 的前提是 TCP 对端属于「环回 + 显式配置的可信代理」;
  • 判定键、记账键、清账键必须出自同一个构造点:三处各自拼键时只要漏改一处,桶就永远判不满,限流会静默失效;
  • 「只对失败计数」必须由原子实现保证,而不是「先判定、失败后再记账」——后者在并发下会让一批请求全部穿透。

新保存的 LDAP 配置若使用非回环的明文 ldap://,默认拒绝保存(bind 密码会明文过网);确需如此要显式设置 ZEOKEEL_LDAP_ALLOW_PLAINTEXT=1,并且每次保存都记一条日志。

上游模型的默认值是 read-only(fail-safe),产品把它与审批策略捆成「预设」呈现:

档位文件效果典型用途
只读不允许任何写入查代码、查资料、做评审
工作区内修改(默认档)只写会话工作区 + 平台临时目录;读不受限日常改代码
完全权限不施加文件效果约束装依赖、动工作区外的路径

可写面由唯一的根派生函数给出:工作区根 + /tmp + 平台临时目录,三者都按 realpath 归一后再比较。读不受限是有意的(AI 要能读工作区里的依赖与系统头文件);这条边界要按「写」来读,不要按「机密性」来读。

平台手段
Linux优先 bwrap(命名空间 + 挂载白名单),其次内核 Landlock
macOSSeatbelt(系统 sandbox-exec)
Windows受限令牌 + 目录 ACL(capability write ACE + Low 强制性标签)
  • 运行器一个都不可用时返回 SANDBOX_UNAVAILABLE,绝不静默地无隔离执行;
  • 每次执行会如实报告强制级别是 full 还是 partial,并区分「策略拒绝」与「运行器故障」两类签名——把不可用或坏掉的沙箱与一次正常的策略拒绝混为一谈,是排障最常见的方向性错误;
  • Windows 的「工作区内修改」要先给工作区根目录写 DACL;拿不到(目录不属于当前账户、映射盘、非 NTFS 等)就 fail-closed:该工作区的每条命令都起不来,报错点名 DACL 并给出两条出路(换目录 / 该会话改用完全权限);
  • 授权是一次性的:批准的是这一次调用,没有「总是允许」;沙箱拒绝与审批拒绝都会作为工具错误回到模型,模型看得见「被谁拒了、为什么」。
  • 唯一有效的一刀是模块作用域的启动开关 no-proxy-server,必须在 app.whenReady() 之前追加:它一次覆盖默认 session 与后建的全部分区(内置浏览器、应用窗口、平台出站请求),并压过宿主机显式传入的代理参数;
  • 只对单个 session 调 setProxy({mode:'direct'}) 不够:分区仍会走代理;
  • 主进程 Node 栈强制使用直连 dispatcher;派生子进程时剥掉代理环境变量(且必须在分层 .env 加载之后剥,否则会被 .env 注入回来);
  • 逃生门(给”只有经代理才能出公网”的部署):渠道包 desktop.allow_system_proxy: true,或真实进程环境变量 ZEOKEEL_ALLOW_SYSTEM_PROXY=1。判定顺序是「真实进程环境 > 渠道包 > 默认禁止」,写在 .env 分层里的开关结构上太晚,启动日志会明示它已被忽略。
  • 客户端只从它登录的那台服务端取升级信息(GET /api/client/v2/updates/manifest);未登录 = 没有更新源,此时不做任何外发检查(不是”检查失败”);
  • 清单必须通过结构校验(schema 为 1、渠道标识与服务端一致、下载地址为绝对 https);安装包流式下载后按清单里的 SHA-256 校验,并按平台容器魔数再校验一次,最后原子重命名就位;任一步不通过都不安装,失败保持当前版本;
  • 安装一律由用户显式触发(Windows 会起安装程序、macOS 打开磁盘映像、Linux 提示替换文件)——产品不做「静默重启安装」;
  • 客户端错误上报默认关闭:只有服务端配置显式开启且给出 DSN 时客户端才初始化上报。

连接器会跟随对端在上一跳里给出的地址(WWW-Authenticate、授权服务器列表、发现文档里的端点),所以出站策略不是”检查一次 URL”:

  1. 协议:https,或 http 仅限环回;
  2. 地址:解析结果落在私网 / 链路本地 / 云 metadata / 组播 / 保留段一律拒;
  3. 解析必须被验证:解析失败、解析超时、解析结果为空,与”解析到非公网地址”同等拒绝(分型上报,但都不是放行);
  4. 钉住连接:把验证过的地址交给这次连接使用,不再做第二次解析,也不回落到系统解析器——没有 pin 就是拒绝;
  5. 重定向:redirect: 'manual',3xx 一律拒(否则授权码与 PKCE 校验值会被送到一个按初始 URL 判定本应被拒的主机);
  6. 代理路由必须与地址判定在同一次判定里决定:代理模式下本地解析结果不再是判据,这是显式选择而不是静默回落;代理配置读不出来 = 拒绝。

服务端会带着凭据访问上游网关(管理员可配地址),因此:

  • 允许私网(企业内网自建 LLM 网关是主要场景);
  • 云 metadata 与链路本地地址一律拒,已知 metadata 主机名双保险;关键在拨号时复检解析结果——保存时的静态校验挡不住 DNS rebinding(保存时公网、请求时解析到 metadata 地址);
  • 「策略拒绝」与「解析/连接失败」是两种类型化错误,诊断方向完全不同,不会互相伪装。
  • 市场与共享内容(技能 / 智能体)是审核 + 授权双门制:未审核、已下架、未授权三种情况一律与「不存在」同 404(不泄露存在性);
  • 授权对象 = 用户或部门组(组名大小写不敏感;部门按归属 + 祖先链继承,主管另按子树继承);管理员恒全量、不落授权表;
  • 应用属于个人:非归属人访问应用端点一律 404,且与「应用不存在」逐字节同形;冻结应用对非归属人同样按不存在处理;
  • 管理端不做「404 伪装」(权限不足返回 403)——那是给审计与运维看的,不是可枚举面。
  1. 所有 /api/server/admin/* 路由必须经统一的注册函数声明权限点,注册表与真实路由表由测试对拍——杜绝「漏挂权限 ⇒ 越权」;
  2. 请求序列固定为 AdminAuth(会话 + CSRF)→ RequirePermission(权限点)→ 业务处理,权限点按角色分配(超级管理员 / 审计员 / 普通用户)。
  • 每条审计记录的哈希 = sha256(prev_hash | username | action | detail | created_at),prev_hash 指向上一条——改写任何一行都会断链;
  • 写入用事务 + 咨询锁串行化(并发插入都读到同一个 prev_hash 会分叉成两条链);
  • 链校验有执行者:启动时校验一次 + 周期校验,结论带新鲜度(超过 2 小时视为过期,界面上不把旧结论当实时结论);清理保留期后链的起点是保留的「锚」,不影响校验;
  • 保留期默认 180 天,可在 1–3650 天之间配置,由周期调度器执行(保存设置时额外立即执行一次);
  • 审计写入失败可观测:进程内计数器 + 最近一次失败的结构化形状,在服务器信息里可读——“静默丢条目”等价于没有审计。

应用平台的隔离结论是能力不存在级,不依赖”过滤恶意输入”:

  • guest 只实例化 wasi_snapshot_preview1,零 preopen、不传 args / env;
  • WASI preview1 没有 sock_open / sock_bind / sock_listen / sock_connect,也没有 spawn,应用拿不到 socket fd、起不了子进程;
  • 宿主能力是一张封闭的方法表(8 个方法),表外的方法直接拒绝并列出可用清单;
  • 单实例内存、guest 时间、宿主调用、单条 SQL、端到端墙钟都有硬上限。

详细的方法表、预算与失败行为见应用中心的「运行时边界」与「边界与失败行为」两节。

以下都是当前实现里真实存在的边界。写出来是为了让评审方按自己的风险偏好决定要不要叠加别的措施,而不是宣称「已经完全安全」。

#残留说明可行的收敛方向
1Windows 安装器与 Linux AppImage 未签名SmartScreen 可能提示”未知发布者”;macOS 正式版已签名 + 公证,预发布版仅签名(无公证票据)采购代码签名证书;预发布包不作为交付物
2macOS 只出 Apple 芯片(arm64) 一条产线没有 Intel 版安装包需要时另开产线(成本与签名链路都变)
3客户端默认禁用任何代理的代价“只有经代理才能出公网”的部署必须显式开逃生门;内置浏览器同样失去代理渠道字段或真实进程环境变量(两者都不依赖服务端下发)
4Windows「工作区内修改」依赖工作区根目录的 DACL 可写拿不到就 fail-closed:该工作区每条命令都起不来,需人工修目录归属或改用完全权限用本账户在本地 NTFS 上创建的目录作为工作区
5本地写面的持有性证明依赖 cookie 保密性打包版拒绝调试类开关(--inspect* 家族由打包期 fuse 兜底),但”同机同用户进程读不到 cookie 罐”这条结构性前提没有变——真正的收口是让写面证明不依赖 cookie 的保密性,未闭环引入与 cookie 无关的持有性证明
6连接器凭据不跨服务端继承旧布局的凭据没有租户标记,相关连接器需要重新授权一次有意的 fail-closed 取舍
7沙箱是同世界的共享宿主内核与文件系统;上游另登记了三条:Windows 的 ACL 强制是 partial(硬链接别名、读不受限、被别的 AppContainer 包 SID 保护过的目录不可读)、老内核上 Landlock 可能 partial、macOS 依赖已废弃的 sandbox-exec(Apple 移除后无法替代)需要强隔离环境时改用容器或远端执行器
8编译缓存的条目没有内容签名编译进程有 OS 级隔离 + 0700 缓存目录,但与宿主同 uid 的写者仍可投毒;闭合需要部署侧独立 uid / 只读挂载 / HMAC 清单部署面加固(服务端已把残余风险写进导出给运维的接口)
9应用的退役快照与真删任务未实现导出端点如实写着”保留期内可导出 → 保留期结束后真删”,但该后台任务尚未实现后续版本
10无系统 keyring 的 Linux 上,客户端会话令牌以 0600 明文落盘安装 gnome-keyring / kwallet 即改走 safeStorage 加密;退化时客户端会打一条告警部署侧安装 keyring
11LDAP 的错误文本脱敏只覆盖常见编码形态目录服务在 bind 请求里就拿到了明文口令,可以任意变形回显;脱敏会擦掉原样 / 大小写 / base64 / URL 编码四种形态,但”部分回显、插入分隔符”这类变形仍可能漏(控制字符一律转义,保证一条错误只占一行)对端错误文本一律不进日志(牺牲排障能力,当前未采用)