Compare commits

..

63 Commits

Author SHA1 Message Date
agent
57c3efb70e feat(auth): 服务端托管 Google OAuth;修复未解锁 vault 时 bootstrap
- API:桌面登录 session、Google 托管回调与轮询
- Desktop:轮询登录;bootstrap 在 vault 未解锁时不返回 shell,避免跳过主密码
- 文档与 deploy/.env.example 对齐 GOOGLE_OAUTH_* 与 SECRETS_PUBLIC_BASE_URL
2026-04-14 22:05:11 +08:00
agent
e6bd2225cd fix(desktop): 解析 Google OAuth 客户端文件路径并更新示例与说明
Some checks failed
Secrets v3 CI / 检查 (push) Failing after 2m5s
2026-04-14 19:40:42 +08:00
agent
328962706b chore(desktop): 跟踪 apps/desktop/dist 静态资源并统一文档与忽略规则 2026-04-14 19:23:58 +08:00
agent
763d99b15e fix(mcp): remove secrets_find/add/update aliases; align docs and repair script
Some checks failed
Secrets v3 CI / 检查 (push) Failing after 2m16s
2026-04-14 18:51:05 +08:00
agent
0374899dab feat(v3): migrate workspace to API, Tauri desktop, and v3 crates; remove legacy MCP stack
Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled
- Add apps/api, desktop Tauri shell, domain/application/crypto/device-auth/infrastructure-db
- Replace desktop-daemon vault integration; drop secrets-core and secrets-mcp*
- Ignore apps/desktop/dist and generated Tauri icons; document icon/dist steps in AGENTS.md
- Apply rustfmt; fix clippy (collapsible_if, HTTP method as str)
2026-04-14 17:37:12 +08:00
voson
cb5865b958 release(secrets-mcp): 0.6.0 - local gateway onboarding and target_exec
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m1s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
2026-04-12 22:47:46 +08:00
voson
34093b0e23 feat: add secrets-mcp-local gateway (proxy, unlock cache, plaintext tool gate) 2026-04-12 22:47:46 +08:00
voson
0bf06bbc73 chore: empty checkpoint 2026-04-12 22:47:46 +08:00
voson
f86d12b80e feat(secrets-mcp): /entries 按 tags 筛选,发版 0.5.27
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m48s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Web:tags 查询参数、folder 计数 SQL、分页与 tab 保留 tags;模板与 i18n
- README / AGENTS / CHANGELOG;plans/web-tags-filter 等计划文档
2026-04-11 21:39:36 +08:00
voson
43d6164a15 fix(oauth): reqwest 启用 system-proxy;Google 换 token 诊断与 0.5.26
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m53s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
- 工作区 reqwest 增加 system-proxy,与系统代理一致
- google.rs:超时/非 2xx 体日志;OAuth 请求单独 45s 超时
- README / AGENTS / deploy/.env.example:OAuth 出站与 HTTPS_PROXY 说明
2026-04-11 21:33:30 +08:00
voson
1b2fbdae4d feat(secrets-mcp): /changelog 页(Markdown 渲染)、首页与 Dashboard 入口
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
- 新增 CHANGELOG.md 构建嵌入、pulldown-cmark 渲染、路由 /changelog
- 首页页脚与 MCP Dashboard 页脚提供变更记录链接;同步 README、AGENTS
- 版本 secrets-mcp 0.5.24
2026-04-11 21:27:26 +08:00
voson
ab1e3329b9 docs: 同步 README/AGENTS — rollback 仅需 Bearer、env_map 命名规则、key_version 与 JSON 会话、导出 secret_types
本次不发版(仅文档)。
2026-04-11 20:41:20 +08:00
voson
c3b1a0df1a merge: code-review fixes (d7720662 baseline + 9f8a 文案常量化、env_prefix 测试、补充用例); secrets-mcp 0.5.21
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m59s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
- default 已 rebase 到 d7720662;合并说明见 plans/merge-code-review-fixes-2026-04-11.md
- Web PATCH 长度错误用 validation 常量拼接;env_map 单测;import/api_key 单测
- rustfmt 收尾
2026-04-11 20:39:01 +08:00
voson
d772066210 fix(secrets-mcp 0.5.20): code review plan — export secret types, env map, rollback, API key, MCP tools, web session & validation
- Export/import: optional secret_types map; AddResult includes entry_id
- env_map: dot→__ segment encoding; collision errors
- rollback: FOR UPDATE + txn-consistent snapshot; restore name from history
- regenerate_api_key: rows_affected guard
- MCP: find count propagates errors; add uses entry_id for relations; rollback no encryption key
- Web: load_session_user_strict + JSON handlers key_version; PATCH length limits
- Tests: ExportEntry serde, env segment
2026-04-11 17:10:28 +08:00
voson
2c7dbf890b docs: add code review fixes plan 2026-04-11 17:04:18 +08:00
voson
8c49316923 release(secrets-mcp): 0.5.19 — 条目列表文件夹列与筛选重置文案;偶数行名称列斑马纹;文档同步 Web 列说明
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m20s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-11 15:33:05 +08:00
voson
cf93488c6a release(secrets-mcp): 0.5.18 — Web 条目密文值编辑,PATCH /api/secrets/:id 支持 value
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-11 15:14:04 +08:00
137a4d42b0 release(secrets-mcp): 0.5.17 — 取消生产环境强制 PG TLS 校验
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m27s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 1m35s
移除 SECRETS_ENV=production 时对 verify-ca/verify-full 的硬性要求,
仍可通过 SECRETS_DATABASE_SSL_MODE 显式选择模式。

Made-with: Cursor
2026-04-10 17:10:55 +08:00
agent
ff2ea91e72 release(secrets-mcp): 0.5.16 — 回收站页面 title 国际化
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m32s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
2026-04-10 14:15:15 +08:00
agent
574c1c9967 release(secrets-mcp): 0.5.15 — 列设置面板锚定优化,移除查看密文隐藏功能
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m53s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-10 11:54:20 +08:00
voson
98d69f5f12 fix(update): include deleted_at in SELECT for EntryWriteRow mapping
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m43s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
The update_fields_by_id query was missing deleted_at column, causing
sqlx FromRow mapping to fail against EntryWriteRow struct.
2026-04-09 20:55:05 +08:00
agent
089d0b4b58 style(dashboard): move version footer out of card
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m30s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m37s
2026-04-09 17:32:40 +08:00
agent
10da51c203 ci: 添加版本 bump 硬检查,防止代码变更未发版
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m47s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- CI 工作流解析版本时检查 crates/ 变更是否伴随版本 bump
- 若代码变更但版本号未变,直接失败并提示
- 与 scripts/release-check.sh 本地检查形成双保险
2026-04-07 14:05:59 +08:00
agent
bc8995cf71 chore: sync Cargo.lock with Cargo.toml version 0.5.12
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
2026-04-07 13:47:51 +08:00
agent
5333b863c5 refactor(entries): 将编辑弹窗中的密文管理功能移到查看密文弹窗
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 1m44s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
- 编辑弹窗移除密文区域(重命名、类型修改、解绑)
- 查看密文弹窗增加:重命名(带 debounce 校验)、类型选择、解绑、保存
- 列表行密文 chips 保留只读展示,移除解绑按钮
- 简化编辑弹窗保存逻辑,不再处理密文变更
- bump 0.5.12
2026-04-07 13:32:29 +08:00
agent
6fde982c20 refactor(entries): 将编辑弹窗中的密文管理功能移到查看密文弹窗
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m6s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- 编辑弹窗移除密文区域(重命名、类型修改、解绑)
- 查看密文弹窗增加:重命名(带 debounce 校验)、类型选择、解绑、保存
- 列表行密文 chips 保留只读展示,移除解绑按钮
- 简化编辑弹窗保存逻辑,不再处理密文变更
2026-04-07 13:25:33 +08:00
agent
a2a80a1744 fix(dashboard): 修正 OpenCode MCP 配置,移除 enabled 字段、添加 oauth: false
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m50s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-07 11:17:03 +08:00
dfe282095c feat(dashboard): OpenCode MCP 配置改用原生 Streamable HTTP,移除 mcp-remote 中转;bump 0.5.11
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-07 10:43:17 +08:00
voson
59084a409d release(secrets-mcp): 0.5.10 — Web 模块化、性能与错误处理
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- 拆分 web.rs 为 web/ 子模块;统一 client_ip 提取
- core: user_scope SQL 复用、env_map N+1 消除、FETCH_ALL 上限调整
- entries 列表页并行查询;PgPool 去 Arc;结构化 NotFound 等错误
- CI: SSH 私钥安全写入;crypto/hex 与依赖清理;MCP 输入长度校验
- AGENTS: API Key 明文存储设计说明
2026-04-06 23:41:07 +08:00
voson
b0fcb83592 release(secrets-mcp): 0.5.9 — users.key_version 与会话失效;Web 条目解密 API 与列表增强
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m24s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-06 17:23:20 +08:00
voson
8942718641 scripts: 添加基于 CSV 的 MCP secrets 重加密修复工具
通过读取 entry_id/secret_name/secret_value 调用 secrets_update 让服务端用当前密钥重加密。附带模板 CSV,.gitignore 忽略 *.pyc。
2026-04-06 16:38:37 +08:00
voson
53d53ff96a release(secrets-mcp): 0.5.8 — 修复更换密码短语流程
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m17s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- secrets-core: change_user_key() 事务内全量解密并重加密 secrets
- web: POST /api/key-change;已有密钥时拒绝 POST /api/key-setup(409)
- dashboard: 更换密码需当前密码,调用 key-change
- 同步 Cargo.lock
2026-04-06 12:04:35 +08:00
voson
cab234cfcb feat(secrets-mcp): 增强 MCP 请求日志与 encryption_key 参数支持
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m28s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
logging.rs:
- 每条 MCP POST 日志新增 auth_key(Bearer token 前12字符掩码)、
  enc_key(X-Encryption-Key 前4后4字符指纹,如 146b…5516(64) 或 absent)、
  user_id、tool_args(白名单非敏感参数摘要)字段
- 新增辅助函数 mask_bearer / mask_enc_key / extract_tool_args / summarize_value

tools.rs:
- extract_enc_key 成功路径增加 debug 级指纹日志(raw_len/trimmed_len/prefix/suffix)
- 新增 extract_enc_key_or_arg / require_user_and_key_or_arg:优先使用参数传入的密钥,
  fallback 到 X-Encryption-Key 头,绕过 Cursor Chat MCP 头透传异常
- GetSecretInput / AddInput / UpdateInput / ExportInput / EnvMapInput 各增加可选
  encryption_key 字段,对应工具实现改用 require_user_and_key_or_arg
2026-04-06 11:03:01 +08:00
voson
e0fee639c1 release(secrets-mcp): 0.5.7
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m8s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-05 17:07:31 +08:00
voson
7c53bfb782 feat(core): entry 历史附带关联 secret 密文快照,rollback 可恢复 N:N 与密文
- db: metadata_with_secret_snapshot / strip / parse 辅助
- add/update/delete/rollback 在写 entries_history 前合并快照
- rollback: 按历史快照同步 entry_secrets、更新或插入 secrets
- 满足 clippy collapsible_if
2026-04-05 17:06:53 +08:00
voson
63cb3a8216 release(secrets-mcp): 0.5.6
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m8s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
修复 OAuth 解绑时非法聚合 FOR UPDATE,Web OAuth 审计 IP 与 TRUST_PROXY 对齐并校验 IP,账号绑定写入 oauth_state 失败时回滚 bind 标记。回滚条目时恢复 folder/type,导入冲突检查在 DB 失败时传播错误,MCP delete/history 要求已登录用户,全局请求体 10MiB 限制。CI 部署支持 DEPLOY_KNOWN_HOSTS,默认 accept-new;文档与 deploy 示例补充连接池、限流、TRUST_PROXY。移除含明文凭据的 sync-test-to-prod 脚本。
2026-04-05 15:29:03 +08:00
voson
2b994141b8 release(secrets-mcp): 0.5.5 — 生产 CORS 显式 allow_methods,修复 tower-http 启动 panic
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m59s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
credentials + wildcard methods/headers 被 tower-http 禁止;生产环境改为 GET/POST/PATCH/DELETE/OPTIONS 白名单。
2026-04-05 12:27:40 +08:00
voson
9d6ac5c13a release(secrets-mcp): 0.5.4 — Web 分页修正与 hex 解码;批量删除上限;MCP @ 路径检测
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s
2026-04-05 12:14:40 +08:00
voson
1860cce86c release(secrets-mcp): 0.5.3 — 审计日志分页与 Web;CONTRIBUTING;文档与模板修正 2026-04-05 11:34:04 +08:00
dd24f7cc44 release: secrets-mcp 0.5.2
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m7s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s
Bump version: secrets-mcp-0.5.1 tag already existed while crates had further changes.

Made-with: Cursor
2026-04-05 10:38:50 +08:00
voson
aefad33870 chore(secrets-mcp): 0.5.1 — 移除 entry type 归一化,MCP 参数兼容字符串形式
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m22s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- 去掉 taxonomy 对 entry type 的自动映射与 metadata.subtype 回填;仅 trim 后入库
- MCP tools:Vec/Map/bool 等可选字段支持 JSON 内嵌字符串解析,并改进解析失败提示
- 新增 deser 单元测试;README/AGENTS 与 models 注释同步

Made-with: Cursor
2026-04-04 21:27:33 +08:00
voson
0ffb81e57f feat: entry update links existing secrets (link_secret_names)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- secrets-core: update flow validates and applies secret links
- secrets-mcp: MCP tool params and UI for managing links on edit
- Align errors and templates; minor crypto/.gitignore tweaks

Made-with: Cursor
2026-04-04 20:30:32 +08:00
voson
4a1654c820 docs: update MCP tools list, env vars, taxonomy and deploy structure 2026-04-04 18:04:35 +08:00
voson
a15e2eaf4a docs: align README with removed SQL migration scripts
Made-with: Cursor
2026-04-04 17:58:26 +08:00
voson
1518388374 chore(release): secrets-mcp 0.4.0
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
Bump version for the N:N entry_secrets data model and related MCP/Web
changes. Remove superseded SQL migration artifacts; rely on auto-migrate.
Add structured errors, taxonomy normalization, and web i18n helpers.

Made-with: Cursor
2026-04-04 17:58:12 +08:00
b99d821644 Merge pull request 'refactor/entry-secret-nn' (#1) from refactor/entry-secret-nn into main
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m42s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s
Reviewed-on: #1
2026-04-03 19:44:47 +08:00
voson
32f275f88a feat(secrets-mcp): bump 0.3.9 and normalize listen address log
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m7s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Prepare a new release version and improve startup log readability by showing localhost for loopback bind addresses without changing runtime binding behavior.

Made-with: Cursor
2026-04-03 19:36:12 +08:00
王松
c6fb457734 feat(nn): entry–secret N:N, unique secret names, web unlink
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 2m37s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Bump secrets-mcp to 0.3.8 (tag 0.3.7 already used).

- Junction table entry_secrets; secrets user-scoped with type
- Per-user unique secrets.name; link_secret_names on add
- Manual migrations + migrate script; MCP/tool and Web updates

Made-with: Cursor
2026-04-03 17:37:04 +08:00
df701f21b9 feat(secrets-mcp): 共享 key 删除时自动迁移并重定向 (v0.3.7)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m4s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
删除仍被 metadata.key_ref 引用的 key 条目时,在同一事务内将密文复制到首个引用方,
其余引用方的 key_ref 重定向到新 owner;env_map 解析 key_ref 时不再限定 type=key。
Web 删除 API 返回 migrated;Dashboard 删除成功后提示迁移。

Bump secrets-mcp to 0.3.7;补充删除迁移相关单测(需 SECRETS_DATABASE_URL)。

Made-with: Cursor
2026-04-03 09:27:20 +08:00
c3c536200e feat(secrets-mcp): Web 条目编辑 API 与 Notes 列表展示优化(0.3.6)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- secrets-core: EntryWriteRow;按 id 更新/删除(含并发冲突与唯一键)
- Web: PATCH/DELETE /api/entries/{id};列表编辑/删除与错误映射
- entries 模板:Notes 限高滚动;空 Notes 不显示占位框
- 版本 0.3.5 → 0.3.6,同步 Cargo.lock

Made-with: Cursor
2026-04-02 14:58:10 +08:00
7909f7102d feat(secrets-mcp): 条目页按 folder/type 筛选并发版 0.3.5
- entries 路由支持 ?folder=&type= 查询,与搜索层 SearchParams 对齐
- 条目列表页增加筛选表单与说明文案
- 版本 0.3.4 → 0.3.5,同步 Cargo.lock

Made-with: Cursor
2026-04-02 14:37:36 +08:00
87a29af82d feat(web): 条目列表页 /entries 与总条数统计
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m56s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- 新增受会话保护的 GET /entries,仅展示 entries 非敏感列
- search: list_entries、count_entries 共享筛选条件;分页与计数不读 secrets
- 侧边栏在 dashboard/audit 增加「条目」入口
- secrets-mcp 0.3.4(tag 尚未存在)

Made-with: Cursor
2026-04-02 11:26:51 +08:00
1b11f7e976 release(secrets-mcp): v0.3.3 — 强制 PostgreSQL TLS 校验
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m54s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 7s
显式引入数据库 TLS 配置并在生产环境拒绝弱 sslmode,避免连接静默降级。同步更新 deploy/README 与运维 runbook,落地 db.refining.ltd 的证书与服务器配置流程。

Made-with: Cursor
2026-04-01 15:18:14 +08:00
08e81363c9 release(secrets-mcp): v0.3.2 — 修复 key_ref 多租户与歧义
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m41s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- env_map:key_ref 解析传入 user_id;支持 folder/name;多条匹配时报错
- 文档同步 key_ref 说明
- bump secrets-mcp 0.3.1 → 0.3.2,更新 Cargo.lock

Made-with: Cursor
2026-03-27 10:45:12 +08:00
voson
beade4503d release(secrets-mcp): v0.3.1
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m45s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- MCP: secrets_find, secrets_overview; secrets_get id-only; id on update/delete/history/rollback
- Add meta_obj/secrets_obj; delete guard; env_map/instructions updates
- Core: resolve_entry_by_id; get_*_by_id validates entry + tenant before decrypt

Made-with: Cursor
2026-03-26 17:35:56 +08:00
voson
409fd78a35 Release secrets-mcp 0.3.0: folder/type schema and MCP folder disambiguation
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m39s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Rename namespace/kind to folder/type on entries, audit_log, and history tables;
  add notes. Unique key is (user_id, folder, name).
- Service layer and MCP tools support name-first lookup with optional folder when
  multiple entries share the same name.
- secrets_delete dry_run uses the same disambiguation as real deletes.
- Add scripts/migrate-v0.3.0.sql for manual DB migration. Refresh README and
  AGENTS.md.

Made-with: Cursor
2026-03-26 15:12:28 +08:00
voson
f7afd7f819 docs: 同步 CI 触发路径、覆盖式 tag/Release 说明与 RUST_LOG 示例
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m11s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- AGENTS.md / README:与 workflow 变更路径、远端 tag 覆盖及非 draft Release 行为一致
- deploy/.env.example:补充可选 RUST_LOG 注释

Made-with: Cursor
2026-03-22 16:15:29 +08:00
voson
719bdd7e08 feat(secrets-mcp): public home at /, login at /login (0.2.2)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m17s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Bump secrets-mcp to 0.2.2 and sync Cargo.lock.

Add home.html landing with SEO and footer link to the refining/secrets
repository; serve it at / and expose /login for sign-in.

Update OAuth error redirects and dashboard unauthenticated redirects to
/login. Improve login page meta tags, back-home link, and OAuth error
alert. Refresh llms.txt and robots.txt.

Made-with: Cursor
2026-03-22 16:11:59 +08:00
voson
1e597559a2 feat(core): FK for user_id columns; MCP search requires user
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Add fk_entries_user_id, fk_entries_history_user_id, fk_audit_log_user_id (ON DELETE SET NULL)
- Add scripts/cleanup-orphan-user-ids.sql for pre-deploy orphan user_id cleanup
- Remove deprecated SERVER_MASTER_KEY / per-user key wrap helpers from secrets-core
- secrets-mcp: require authenticated user for secrets_search; improve body-read failure response
- Bump secrets-mcp to 0.2.1

Made-with: Cursor
2026-03-22 15:40:02 +08:00
voson
e3ca43ca3f release(secrets-mcp): 0.2.0
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m12s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- 日志时间戳使用本地时区(chrono RFC3339 + 偏移)
- MCP tools / Web 路由与行为调整
- 新增 static/llms.txt、robots.txt;文档与 deploy 示例同步

Made-with: Cursor
2026-03-22 14:44:00 +08:00
voson
0b57605103 feat(secrets-mcp): MCP 请求日志、探测 404 与资源元数据
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- 新增 logging 中间件:记录 client_ip、ua、JSON-RPC、tool 等
- tools 各入口/出口结构化日志
- 探测型 404(/.well-known、GET /mcp)降为 debug
- /.well-known/oauth-protected-resource 最小元数据
- secrets-mcp 0.1.11

Made-with: Cursor
2026-03-21 17:57:10 +08:00
voson
8b191937cd docs(AGENTS): 精简提交/推送规则第4条
Made-with: Cursor
2026-03-21 16:56:06 +08:00
voson
11c936a5b8 docs(AGENTS): 明确提交/推送前必须检查版本号与运行 fmt/clippy/test
Made-with: Cursor
2026-03-21 16:48:47 +08:00
100 changed files with 24397 additions and 7110 deletions

View File

@@ -1,5 +1,4 @@
# MCP 分支:仅构建/发布 secrets-mcpCLI 在 main 分支维护)
name: Secrets MCP — Build & Release
name: Secrets v3 CI
on:
push:
@@ -18,7 +17,6 @@ permissions:
contents: write
env:
MCP_BINARY: secrets-mcp
RUST_TOOLCHAIN: 1.94.0
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
@@ -28,34 +26,14 @@ env:
jobs:
ci:
name: 检查 / 构建 / 发版
name: 检查
runs-on: debian
timeout-minutes: 40
outputs:
tag: ${{ steps.ver.outputs.tag }}
version: ${{ steps.ver.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# ── 版本解析 ────────────────────────────────────────────────────────
- name: 解析版本
id: ver
run: |
version=$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
tag="secrets-mcp-${version}"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "⚠ 版本 ${tag} 已存在,将覆盖重新发版。"
echo "tag_exists=true" >> "$GITHUB_OUTPUT"
else
echo "将创建新版本 ${tag}"
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
fi
# ── Rust 工具链 ──────────────────────────────────────────────────────
- name: 安装 Rust 与 musl 工具链
run: |
@@ -95,76 +73,13 @@ jobs:
- name: test
run: cargo test --locked
# ── 构建(质量检查通过后才执行)────────────────────────────────────
- name: 构建 secrets-mcp (musl)
- name: 构建 secrets-api
run: |
cargo build --release --locked --target "${MUSL_TARGET}" -p secrets-mcp
strip "target/${MUSL_TARGET}/release/${MCP_BINARY}"
cargo build --release --locked -p secrets-api
- name: 上传构建产物
uses: actions/upload-artifact@v3
with:
name: ${{ env.MCP_BINARY }}-linux-musl
path: target/${{ env.MUSL_TARGET }}/release/${{ env.MCP_BINARY }}
retention-days: 3
# ── 创建 / 覆盖 Tag构建成功后才打───────────────────────────────
- name: 创建 Tag
- name: 构建 secrets-desktop-daemon
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
tag="${{ steps.ver.outputs.tag }}"
if [ "${{ steps.ver.outputs.tag_exists }}" = "true" ]; then
git tag -d "$tag" 2>/dev/null || true
git push origin ":refs/tags/$tag" 2>/dev/null || true
fi
git tag -a "$tag" -m "Release $tag"
git push origin "$tag"
# ── Release可选需配置 RELEASE_TOKEN───────────────────────────
- name: Upsert Release
if: env.RELEASE_TOKEN != ''
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
tag="${{ steps.ver.outputs.tag }}"
version="${{ steps.ver.outputs.version }}"
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
auth="Authorization: token $RELEASE_TOKEN"
previous_tag=$(git tag --list 'secrets-mcp-*' --sort=-v:refname | awk -v t="$tag" '$0 != t { print; exit }')
if [ -n "$previous_tag" ]; then
changes=$(git log --pretty=format:'- %s (%h)' "${previous_tag}..HEAD")
else
changes=$(git log --pretty=format:'- %s (%h)')
fi
[ -z "$changes" ] && changes="- 首次发布"
body=$(printf '## 变更日志\n\n%s' "$changes")
# Upsert: 存在 → PATCH + 清旧 assets不存在 → POST
release_id=$(curl -sS -H "$auth" "${api}/tags/${tag}" 2>/dev/null | jq -r '.id // empty')
if [ -n "$release_id" ]; then
curl -sS -o /dev/null -H "$auth" -H "Content-Type: application/json" \
-X PATCH "${api}/${release_id}" \
-d "$(jq -n --arg n "secrets-mcp ${version}" --arg b "$body" '{name:$n,body:$b,draft:false}')"
curl -sS -H "$auth" "${api}/${release_id}/assets" | \
jq -r '.[].id' | xargs -I{} curl -sS -o /dev/null -H "$auth" -X DELETE "${api}/${release_id}/assets/{}"
echo "已更新 Release ${release_id}"
else
release_id=$(curl -fsS -H "$auth" -H "Content-Type: application/json" \
-X POST "$api" \
-d "$(jq -n --arg t "$tag" --arg n "secrets-mcp ${version}" --arg b "$body" \
'{tag_name:$t,name:$n,body:$b,draft:false}')" | jq -r '.id')
echo "已创建 Release ${release_id}"
fi
bin="target/${MUSL_TARGET}/release/${MCP_BINARY}"
archive="${MCP_BINARY}-${tag}-x86_64-linux-musl.tar.gz"
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")"
sha256sum "$archive" > "${archive}.sha256"
curl -fsS -H "$auth" -F "attachment=@${archive}" "${api}/${release_id}/assets"
curl -fsS -H "$auth" -F "attachment=@${archive}.sha256" "${api}/${release_id}/assets"
echo "Release ${tag} 已发布"
cargo build --release --locked -p secrets-desktop-daemon
# ── 飞书汇总通知 ─────────────────────────────────────────────────────
- name: 飞书通知
@@ -173,76 +88,14 @@ jobs:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
tag="${{ steps.ver.outputs.tag }}"
commit="${{ github.event.head_commit.message }}"
[ -z "$commit" ] && commit="${{ github.sha }}"
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets-mcp 构建&发版 ${icon}
版本:${tag}
msg="secrets v3 CI ${icon}
提交:${commit}
作者:${{ github.actor }}
详情:${url}"
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
deploy:
name: 部署 secrets-mcp
needs: [ci]
if: |
github.ref == 'refs/heads/main' ||
github.ref == 'refs/heads/feat/mcp' ||
github.ref == 'refs/heads/mcp'
runs-on: debian
timeout-minutes: 10
steps:
- name: 下载构建产物
uses: actions/download-artifact@v3
with:
name: ${{ env.MCP_BINARY }}-linux-musl
path: /tmp/artifact
- name: 部署到阿里云 ECS
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then
echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY"
exit 0
fi
echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
scp -i /tmp/deploy_key -o StrictHostKeyChecking=no \
"/tmp/artifact/${MCP_BINARY}" \
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new"
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" "
sudo mv /tmp/secrets-mcp.new /opt/secrets-mcp/secrets-mcp
sudo chmod +x /opt/secrets-mcp/secrets-mcp
sudo systemctl restart secrets-mcp
sleep 2
sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1)
"
rm -f /tmp/deploy_key
- name: 飞书通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
tag="${{ needs.ci.outputs.tag }}"
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets-mcp 部署 ${icon}
版本:${tag}
作者:${{ github.actor }}
详情:${url}"
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"

12
.gitignore vendored
View File

@@ -2,6 +2,14 @@
.env
.DS_Store
.cursor/
# Google OAuth 下载的 JSON 凭据文件
*.pem
tmp/
client_secret_*.apps.googleusercontent.com.json
*.pem
node_modules/
*.pyc
# Tauri app icon pack: generated by `cargo tauri icon apps/desktop/src-tauri/icons/icon.png`
# Version control only the 1024×1024 master; regenerate the rest locally or in release builds.
apps/desktop/src-tauri/icons/**
!apps/desktop/src-tauri/icons/
!apps/desktop/src-tauri/icons/icon.png

47
.vscode/tasks.json vendored
View File

@@ -1,47 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "mcp: build",
"type": "shell",
"command": "cargo build --locked -p secrets-mcp",
"group": "build",
"options": {
"envFile": "${workspaceFolder}/.env"
}
},
{
"label": "mcp: run",
"type": "shell",
"command": "cargo run --locked -p secrets-mcp",
"options": {
"envFile": "${workspaceFolder}/.env"
}
},
{
"label": "test: workspace",
"type": "shell",
"command": "cargo test --workspace --locked",
"dependsOn": "build",
"group": { "kind": "test", "isDefault": true }
},
{
"label": "fmt: check",
"type": "shell",
"command": "cargo fmt -- --check",
"problemMatcher": []
},
{
"label": "clippy: workspace",
"type": "shell",
"command": "cargo clippy --workspace --locked -- -D warnings",
"dependsOn": "build"
},
{
"label": "ci: release-check",
"type": "shell",
"command": "./scripts/release-check.sh",
"problemMatcher": []
}
]
}

346
AGENTS.md
View File

@@ -1,128 +1,43 @@
# Secrets MCP — AGENTS.md
# Secrets — AGENTS.md
本仓库为 **MCP SaaS**`secrets-core`(业务与持久化)+ `secrets-mcp`Streamable HTTP MCP、Web、OAuth、API Key。对外入口见 `crates/secrets-mcp`
本仓库当前**v3 桌面端架构**
## 提交 / 发版硬规则(优先于下文)
- `apps/api`:远端 JSON API
- `apps/desktop/src-tauri`:桌面客户端
- `crates/desktop-daemon`:本地 MCP daemon
- `crates/application` / `domain` / `infrastructure-db`v3 业务与数据层
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」
2. 发版前检查 `crates/secrets-mcp/Cargo.toml``version`,再查 tag`git tag -l 'secrets-mcp-*'`
3. 若当前版本对应 tag 已存在,默认允许复用现有 tag 继续构建;仅在需要新的发布版本时再 bump `version``cargo build` 同步 `Cargo.lock`
4. 提交前优先运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。
`secrets-core` / `secrets-mcp` / `secrets-mcp-local` 已移除,不再作为开发入口
## 项目结构
## 版本控制
```
secrets/
Cargo.toml
crates/
secrets-core/ # db / crypto / models / audit / service
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
scripts/
release-check.sh
setup-gitea-actions.sh
.gitea/workflows/secrets.yml
.vscode/tasks.json
```
本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)** 作为版本控制系统(纯 jj 模式,无 `.git` 目录)。
## 数据库
### 常用 jj 命令对照
- **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。
- **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。
- **表**`entries`(含 `user_id`)、`secrets``entries_history``secrets_history``audit_log``users``oauth_accounts`,首次连接 **auto-migrate**
| 操作 | jj 命令 |
|------|---------|
| 查看历史 | `jj log` / `jj log 'all()'` |
| 查看状态 | `jj status` |
| 新建提交 | `jj commit` |
| 创建新变更 | `jj new` |
| 变基 | `jj rebase` |
| 合并提交 | `jj squash` |
| 撤销操作 | `jj undo` |
| 查看标签 | `jj tag list` |
| 查看分支 | `jj bookmark list` |
| 推送远端 | `jj git push` |
| 拉取远端 | `jj git fetch` |
### 表结构(摘录)
### 注意事项
```sql
entries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID, -- 多租户NULL=遗留行;非空=归属用户
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```
```sql
secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL,
encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(entry_id, field_name)
)
```
### users / oauth_accounts
```sql
users (
id UUID PRIMARY KEY DEFAULT uuidv7(),
email VARCHAR(256),
name VARCHAR(256) NOT NULL DEFAULT '',
avatar_url TEXT,
key_salt BYTEA, -- PBKDF2 salt32B首次设置密码短语时写入
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
api_key TEXT UNIQUE, -- MCP Bearer token当前实现为明文存储
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
oauth_accounts (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL,
provider_id VARCHAR(256) NOT NULL,
...
UNIQUE(provider, provider_id)
)
```
### audit_log / history
与迁移脚本一致:`audit_log``entries_history``secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs``migrate` SQL。`audit_log` 中普通业务事件的 `namespace/kind/name` 对应 entry 坐标;登录类事件固定使用 `namespace='auth'`,此时 `kind/name` 表示认证目标而非 entry 身份。
### 字段职责
| 字段 | 含义 | 示例 |
|------|------|------|
| `namespace` | 隔离空间 | `refining` |
| `kind` | 记录类型 | `server`, `service`, `key` |
| `name` | 标识名 | `gitea`, `i-example0…` |
| `tags` | 标签 | `["aliyun","prod"]` |
| `metadata` | 明文描述 | `ip``url``key_ref` |
| `secrets.field_name` | 加密字段名(明文) | `token`, `ssh_key` |
| `secrets.encrypted` | 密文 | AES-GCM |
### PEM 共享(`key_ref`
将共享 PEM 存为 `kind=key` 的 entry其它记录在 `metadata.key_ref` 指向该 key 的 `name`。更新 key 记录后,引用方通过服务层解析合并逻辑即可使用新密钥(实现见 `secrets_core::service`)。
## 代码规范
- 错误:业务层 `anyhow::Result`,避免生产路径 `unwrap()`
- 异步:`tokio` + `sqlx` async。
- SQL`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。
- 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。
- 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`不持有原始密钥。Web 客户端在浏览器本地完成加解密MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
- MCPtools 参数与 JSON Schema`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
- 本仓库为纯 `jj` 模式,本地不要使用 `git` 命令。
- CI Runner 侧仍可能使用 `git` 拉代码,这不影响本地开发。
- 检查 tag 是否存在时,使用 `jj log --no-graph --revisions "tag(${tag})"`
## 提交前检查
```bash
./scripts/release-check.sh
```
或手动:
每次提交前至少运行:
```bash
cargo fmt -- --check
@@ -130,31 +45,196 @@ cargo clippy --locked -- -D warnings
cargo test --locked
```
发版前确认未重复 tag
也可以直接运行
```bash
grep '^version' crates/secrets-mcp/Cargo.toml
git tag -l 'secrets-mcp-*'
./scripts/release-check.sh
```
## CI/CD
## 项目结构
- **触发**:任意分支 `push`,且路径含 `crates/**``deploy/**`、根目录 `Cargo.toml``Cargo.lock`(见 `.gitea/workflows/secrets.yml`)。
- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;若远程已存在同名 `secrets-mcp-<version>` tag则复用现有 tag 继续构建;否则由 CI 创建并推送该 tag。
- **质量与构建**`fmt` / `clippy --locked` / `test --locked``x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`
- **Release可选**`secrets.RELEASE_TOKEN`Gitea PAT用于创建草稿 Release、上传 `tar.gz` + `.sha256`、构建成功后发布;未配置则跳过 API Release仅 tag + 构建。
- **部署(可选)**:仅 `main``feat/mcp``mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow`deploy/.env.example` 在目标机配置。
- **Secrets 写法**Actions **secrets 须为原始值**PEM、PAT 明文),**勿** base64否则 SSH/Release 会失败。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。
- **通知**`vars.WEBHOOK_URL`(可选,飞书)。
```text
secrets/
Cargo.toml
apps/
api/ # 远端 JSON API
desktop/src-tauri/ # 桌面端
crates/
application/ # v3 应用服务
client-integrations/ # Cursor / Claude Code 配置注入
crypto/ # 通用加密辅助
desktop-daemon/ # 本地 MCP daemon
device-auth/ # 设备登录 / Desktop OAuth 辅助
domain/ # v3 领域模型
infrastructure-db/ # 数据库与迁移
deploy/
scripts/
.gitea/workflows/
.vscode/tasks.json
```
## 环境变量secrets-mcp
## 数据库
| 变量 | 说明 |
|------|------|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 |
| `BASE_URL` | 对外基址OAuth 回调 `${BASE_URL}/auth/google/callback`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
| `RUST_LOG` | 如 `secrets_mcp=debug`。 |
- 建议数据库名:`secrets-v3`
- 连接串:`SECRETS_DATABASE_URL`
- 首次连接会自动运行 `secrets-infrastructure-db::migrate_current_schema`
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。
当前 v3 主要表:
- `users`
- `oauth_accounts`
- `devices`
- `device_login_tokens`
- `auth_events`
- `vault_objects`
- `vault_object_revisions`
### 当前模型约束
- 服务端只保存同步所需的密文对象与版本信息
- 搜索、详情、reveal、history 主要在 desktop 本地 vault 中完成
- 删除通过对象级 `deleted_at` / tombstone 传播
- 历史服务端保留在 `vault_object_revisions`,本地另有 `vault_object_history`
### 字段职责
| 字段 | 含义 | 示例 |
|------|------|------|
| `object_id` | 同步对象标识 | `UUID` |
| `object_kind` | 当前对象类别 | `cipher` |
| `revision` | 对象版本号 | `12` |
| `cipher_version` | 密文封装版本 | `1` |
| `ciphertext` | 密文对象载荷 | AES-GCM 密文 |
| `content_hash` | 密文内容摘要 | `sha256:...` |
| `deleted_at` | 对象删除时间 | `2026-04-14T12:00:00Z` |
## Google 登录
当前登录流为 **Google Desktop OAuth**
- 桌面端使用系统浏览器拉起 Google 授权
- API 服务端持有 Google OAuth client 配置并处理 callback / token exchange
- desktop 创建一次性 login session打开托管登录页后轮询状态
- API 校验 Google userinfo 后发放本地 device token
官网 DMG 正式分发时,服务端至少需要配置:
- `SECRETS_PUBLIC_BASE_URL`
- `GOOGLE_OAUTH_CLIENT_ID`
- `GOOGLE_OAUTH_CLIENT_SECRET`
- `GOOGLE_OAUTH_REDIRECT_URI`
推荐约束:
- `SECRETS_PUBLIC_BASE_URL` 使用用户浏览器实际访问的 HTTPS 官网地址
- `GOOGLE_OAUTH_REDIRECT_URI` 配置为 `${SECRETS_PUBLIC_BASE_URL}/auth/google/callback`
- `GOOGLE_OAUTH_CLIENT_SECRET` 只保留在服务端环境变量或密钥管理系统中,不入库
- Google Cloud Console 中登记的 callback URL 必须与 `GOOGLE_OAUTH_REDIRECT_URI` 完全一致
## MCP
本地 MCP 入口由 `crates/desktop-daemon` 提供,默认地址:
```text
http://127.0.0.1:9515/mcp
```
当前暴露的工具:
- `secrets_entry_find`
- `secrets_entry_get`
- `secrets_entry_add`
- `secrets_entry_update`
- `secrets_entry_delete`
- `secrets_entry_restore`
- `secrets_secret_add`
- `secrets_secret_update`
- `secrets_secret_delete`
- `secrets_secret_history`
- `secrets_secret_rollback`
- `target_exec`
当前不保留:
- `secrets_env_map`
### `target_exec`
`target_exec` 会显式读取 entry 当前 secrets 的真实值,并从 metadata / secrets 派生标准环境变量,例如:
- `TARGET_ENTRY_ID`
- `TARGET_NAME`
- `TARGET_FOLDER`
- `TARGET_TYPE`
- `TARGET_HOST`
- `TARGET_PORT`
- `TARGET_USER`
- `TARGET_BASE_URL`
- `TARGET_API_KEY`
- `TARGET_TOKEN`
- `TARGET_SSH_KEY`
## 桌面端
桌面端当前支持:
- Google 登录
- 自动写入 `Cursor` / `Claude Code``mcp.json`
- 新建条目
- 搜索、按 type 筛选
- 右侧原地编辑
- secret 新增、编辑、删除
- secret 明文显示 / 复制
- secret 历史查看与回滚
- 删除到最近删除与恢复
- 登录态仅在当前 desktop 进程内有效,不做自动恢复登录
- desktop 进程退出后,本地 daemon 所有工具不可用
### 配置注入
桌面端会把本地 daemon 配置写入:
- `~/.cursor/mcp.json`
- `~/.claude/mcp.json`
写入策略:
- 保留现有其它 `mcpServers`
- 仅覆盖同名 `secrets` 节点
### 图标与前端 dist本地 / CI
版本库为减小噪音,**不提交** Tauri 生成的多尺寸图标包;但 **`apps/desktop/dist/`** 现在作为桌面端前端静态资源目录,**需要提交到版本库**,以保证新机器 clone 后可直接运行 Tauri desktop。
- **图标**:仅跟踪 `apps/desktop/src-tauri/icons/icon.png` 作为源图(建议 **1024×1024** PNG。检出代码后若需要完整 `icons/`(例如打包、验证窗口/托盘图标),在 **`apps/desktop/src-tauri`** 下执行:
```bash
cd apps/desktop/src-tauri
cargo tauri icon icons/icon.png
```
需已安装 **Tauri CLI**(例如 `cargo install tauri-cli`,或与项目一致的 `cargo-tauri` 版本)。
- **前端 dist**`tauri.conf.json` 中 `build.frontendDist` 指向 `../dist`。当前仓库直接跟踪 **`apps/desktop/dist/`** 下的静态页面资源,因此新机器 clone 后无需额外生成前端产物即可运行 `cargo run -p secrets-desktop`。若后续引入独立前端构建链,再单独把这部分切回构建产物管理。
## 代码规范
- 业务层优先使用 `anyhow::Result`
- 避免生产路径 `unwrap()`
- 使用 `tokio` + `sqlx` async
- SQL 使用参数绑定,不要手拼用户输入
- 运维日志使用 `tracing`
- 变更后优先跑最小必要验证,不要只改不测
## CI / 脚本
- `.gitea/workflows/secrets.yml` 现在是 v3 workspace 级 CI
- `scripts/release-check.sh` 只做 workspace 质量检查
- `deploy/.env.example` 反映当前 v3 API / daemon / desktop 登录配置
## 安全约束
- 不要把 Google `client_secret` 提交到受版本控制的配置文件中
- 不要把 device token、数据库密码、真实生产密钥提交入库
- 数据库生产环境优先使用 `verify-full`
- AI 审查时,不要把“随机高熵 token 明文存储”机械地当成密码学问题处理,必须结合当前架构和威胁模型判断

56
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,56 @@
# Contributing
## 版本控制
本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)**。请勿使用 `git` 命令。
```bash
jj log # 查看历史
jj status # 查看状态
jj new # 创建新变更
jj commit # 提交
jj rebase # 变基
jj squash # 合并提交
jj git push # 推送到远端
```
详见 [AGENTS.md](AGENTS.md) 的「版本控制」章节。
## 本地开发
```bash
# 复制环境变量
cp deploy/.env.example .env
# 填写数据库连接等配置后
cargo build
cargo test --locked
```
## 提交前检查
每次提交前必须通过:
```bash
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
或使用脚本:
```bash
./scripts/release-check.sh
```
## 发版规则
当前仓库已切换到 v3 架构,不再围绕 `secrets-mcp` 做单独发版。
提交前请至少保证:
1. `cargo fmt -- --check`
2. `cargo clippy --locked -- -D warnings`
3. `cargo test --locked`
详见 [AGENTS.md](AGENTS.md) 中最新的仓库说明。

3571
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,14 @@
[workspace]
members = [
"crates/secrets-core",
"crates/secrets-mcp",
"apps/api",
"apps/desktop/src-tauri",
"crates/application",
"crates/client-integrations",
"crates/crypto",
"crates/desktop-daemon",
"crates/device-auth",
"crates/domain",
"crates/infrastructure-db",
]
resolver = "2"
@@ -13,7 +20,7 @@ edition = "2024"
tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] }
# Database
sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }
sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite", "uuid", "json", "chrono"] }
# Serialization
serde = { version = "^1.0.228", features = ["derive"] }
@@ -25,14 +32,23 @@ toml = "^1.0.7"
aes-gcm = "^0.10.3"
sha2 = "^0.10.9"
rand = "^0.10.0"
hex = "0.4"
# Utils
anyhow = "^1.0.102"
thiserror = "^2"
chrono = { version = "^0.4.44", features = ["serde"] }
uuid = { version = "^1.22.0", features = ["serde"] }
uuid = { version = "^1.22.0", features = ["serde", "v4"] }
tracing = "^0.1"
tracing-subscriber = { version = "^0.3", features = ["env-filter"] }
dotenvy = "^0.15"
# HTTP
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] }
# system-proxy与浏览器一致读取 macOS/Windows 系统代理(禁用 default 后须显式开启,否则 OAuth 出站不走 Clash 等)
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] }
axum = "0.8"
http = "1"
url = "2"
rmcp = { version = "1", features = ["server", "macros", "transport-streamable-http-server", "schemars"] }
tauri = { version = "2", features = [] }
tauri-build = { version = "2", features = [] }

275
README.md
View File

@@ -1,119 +1,144 @@
# secrets-mcp
# Secrets
Workspace**`secrets-core`** + **`secrets-mcp`**HTTP Streamable MCP + Web。多租户密钥与元数据存 PostgreSQL用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥。
这是 v3 架构的仓库,当前主路径已经收敛为:
## 安装
- `apps/api`:远端 JSON API
- `apps/desktop/src-tauri`:桌面客户端
- `crates/desktop-daemon`:本地 MCP 入口
- `crates/application` / `domain` / `infrastructure-db`:业务与数据层
## 本地开发
```bash
cargo build --release -p secrets-mcp
# 产物: target/release/secrets-mcp
cp deploy/.env.example .env
# 远端 API
cargo run -p secrets-api --bin secrets-api
# 本地 daemon
cargo run -p secrets-desktop-daemon
# 桌面客户端
cargo run -p secrets-desktop
```
发版产物见 Gitea Releasetag`secrets-mcp-<version>`Linux musl 预编译);其它平台本地 `cargo build`
说明:
## 环境变量与本地运行
- `apps/desktop/src-tauri/tauri.conf.json``build.frontendDist` 指向 `apps/desktop/dist`
- 当前仓库会直接提交 `apps/desktop/dist/` 下的桌面端静态资源
- 因此新机器 clone 后,无需额外前端构建步骤即可启动 desktop
- 官网 DMG 正式分发不依赖本地 `client_secret_*.json`
- Google OAuth 凭据只配置在 API 服务端desktop 通过浏览器完成托管登录
复制 `deploy/.env.example` 为项目根目录 `.env`(已在 `.gitignore`),或导出同名变量:
## 官网 DMG 的服务端 OAuth 配置
| 变量 | 说明 |
|------|------|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(建议专用库,如 `secrets-mcp`)。 |
| `BASE_URL` | 对外访问基址OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。反代时常为 `127.0.0.1:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 |
官网 DMG 正式分发时,**Google OAuth 只配置在 API 服务端**。桌面端不需要本地 `client_secret_*.json`,也不直接向 Google 换 token。
建议先复制 `deploy/.env.example``.env`,然后至少配置以下变量:
```bash
cargo run -p secrets-mcp
SECRETS_PUBLIC_BASE_URL=https://secrets.example.com
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
GOOGLE_OAUTH_REDIRECT_URI=https://secrets.example.com/auth/google/callback
```
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key
- **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头。
变量含义:
## 加密架构(混合 E2EE
- `SECRETS_PUBLIC_BASE_URL`:桌面端打开浏览器时访问的 API 外网基地址,必须是用户浏览器能访问到的公开地址
- `GOOGLE_OAUTH_CLIENT_ID`Google Cloud Console 中为服务端登录流程配置的 OAuth Client ID
- `GOOGLE_OAUTH_CLIENT_SECRET`:对应的 Client Secret只能保留在服务端
- `GOOGLE_OAUTH_REDIRECT_URI`Google 登录完成后回调到 API 的地址,必须与 Google Console 中登记的回调地址完全一致
### 密钥派生
配置步骤建议:
用户在 Web Dashboard 设置**密码短语**,浏览器使用 **Web Crypto APIPBKDF2-SHA256600k 次迭代)**在本地派生 256-bit AES 密钥。
1. 在 Google Cloud Console 创建或选择 OAuth Client
2. 把授权回调地址加入允许列表,例如 `https://secrets.example.com/auth/google/callback`
3. 把上面的 4 个变量配置到 API 服务的运行环境中
4. 确认 `SECRETS_PUBLIC_BASE_URL``GOOGLE_OAUTH_REDIRECT_URI` 使用同一公开域名
5. 重启 API 服务后,再用 desktop / DMG 验证浏览器登录流程
- **Salt32B**:首次设置时在浏览器生成,存入服务端 `users.key_salt`
- **key_check**:派生密钥加密已知常量 `"secrets-mcp-key-check"`,存入 `users.key_check`,用于登录时验证密码短语
- **服务端不存储原始密钥**,只存 salt + key_check
注意:
跨设备同步:新设备登录 → 输入相同密码短语 → 从服务端取 salt → 同样的 PBKDF2 → 得到相同密钥。
- `GOOGLE_OAUTH_CLIENT_SECRET` 不要提交到仓库
- `GOOGLE_OAUTH_REDIRECT_URI` 不要写成 `localhost`,正式分发应使用官网可访问域名
- 如果 API 部署在反向代理后面,`SECRETS_PUBLIC_BASE_URL` 应填写用户实际访问的 HTTPS 地址,而不是内网监听地址
### 写入与读取流程
## 当前能力
```mermaid
flowchart LR
subgraph Web["Web 浏览器E2E"]
P["密码短语"] --> K["PBKDF2 → 256-bit key"]
K --> Enc["AES-256-GCM 加密"]
K --> Dec["AES-256-GCM 解密"]
end
- 桌面端使用系统浏览器完成 Google Desktop OAuth 登录
- 登录成功后向 API 注册设备,并在当前桌面进程内维护登录会话
- 本地 daemon 提供显式拆分的 MCP 工具:
- `secrets_entry_find` / `secrets_entry_get`
- `secrets_entry_add` / `secrets_entry_update` / `secrets_entry_delete` / `secrets_entry_restore`
- `secrets_secret_add` / `secrets_secret_update` / `secrets_secret_delete`
- `secrets_secret_history` / `secrets_secret_rollback`
- `target_exec`
- 桌面端会自动把本地 daemon MCP 配置写入 `Cursor``Claude Code`
- 桌面端支持条目新建、搜索、按 type 筛选、元数据编辑、最近删除与恢复
- 桌面端支持 secret 新增、编辑、删除、明文显示、真实复制、历史查看与回滚
- 不保留 `secrets_env_map`
- 不做自动恢复登录;重启 app 后必须重新登录
subgraph AI["AI 客户端MCP"]
HdrKey["X-Encryption-Key: hex"]
end
## 提交前检查
subgraph Server["secrets-mcp 服务端"]
Middleware["请求中临时持有 key\n请求结束即丢弃"]
DB[(PostgreSQL\nsecrets.encrypted = 密文\nentries.metadata = 明文)]
end
Enc -->|密文| Server
HdrKey -->|key + 请求| Middleware
Middleware <-->|加解密| DB
DB -->|密文| Dec
```bash
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
### 两种客户端对比
## PostgreSQL TLS 加固
| | Web 浏览器 | AI 客户端MCP |
|---|---|---|
| 密钥位置 | 仅在浏览器内存 / sessionStorage | MCP 配置 headers 中 |
| 加解密位置 | 客户端(真正 E2E | 服务端临时(请求级生命周期) |
| 安全边界 | 服务端零知识 | 依赖 TLS + 服务端内存隔离 |
- 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`
- 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA并保证证书 `SAN` 包含 `db.refining.ltd`
- PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。
- 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca`
- 可执行运维步骤见 `[deploy/postgres-tls-hardening.md](deploy/postgres-tls-hardening.md)`
### 敏感数据传输
## MCP 与 AI 工作流v3
当前 v3 以 **桌面端 + 本地 daemon** 为主路径:
- 桌面端登录态仅在当前进程内有效,不持久化 `device token`
- 本地 daemon 默认监听 `http://127.0.0.1:9515/mcp`
- daemon 通过活跃 desktop 进程提供的本地会话转发访问 APIdesktop 进程退出后所有工具不可用
- `target_exec` 会显式读取真实 secret 值后再生成 `TARGET_`* 环境变量
- 不保留 `secrets_env_map`
### Canonical MCP 工具
| 工具 | 说明 |
| ------------------------- | --------------------------------------------------------- |
| `secrets_entry_find` | 从 desktop 已解锁本地 vault 搜索对象,支持 `query` / `folder` / `type` |
| `secrets_entry_get` | 读取单条本地对象,并返回当前 secrets 的真实值 |
| `secrets_entry_add` | 在本地 vault 创建对象,可选附带初始 secrets |
| `secrets_entry_update` | 更新本地对象的 folder / type / name / metadata |
| `secrets_entry_delete` | 将本地对象标记为删除 |
| `secrets_entry_restore` | 恢复本地已删除对象 |
| `secrets_secret_add` | 向已有本地对象新增 secret |
| `secrets_secret_update` | 更新本地 secret 名称、类型或内容 |
| `secrets_secret_delete` | 删除单个本地 secret |
| `secrets_secret_history` | 查看单个本地 secret 的历史版本 |
| `secrets_secret_rollback` | 将单个本地 secret 回滚到指定版本 |
| `target_exec` | 用本地对象的 metadata 和 secrets 生成 `TARGET_`* 环境变量并执行本地命令 |
- **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器
- **API Key** 当前存放在 `users.api_key`Dashboard 会明文展示并可重置
- **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化)
- **生产环境必须走 HTTPS/TLS**
## AI 客户端配置
在 Web Dashboard 设置密码短语后,解锁页面会按客户端格式生成配置。常见客户端示例如下
桌面端会自动把本地 daemon 写入以下配置
`Cursor / Claude Desktop` 风格:
- `~/.cursor/mcp.json`
- `~/.claude/mcp.json`
写入示例:
```json
{
"mcpServers": {
"secrets": {
"url": "https://secrets.example.com/mcp",
"headers": {
"Authorization": "Bearer sk_abc123...",
"X-Encryption-Key": "a1b2c3...64位hex"
}
}
}
}
```
`OpenCode` 风格:
```json
{
"mcp": {
"secrets": {
"type": "remote",
"enabled": true,
"url": "https://secrets.example.com/mcp",
"headers": {
"Authorization": "Bearer sk_abc123...",
"X-Encryption-Key": "a1b2c3...64位hex"
}
"url": "http://127.0.0.1:9515/mcp"
}
}
}
@@ -121,58 +146,78 @@ flowchart LR
## 数据模型
主表 **`entries`**`namespace``kind``name``tags``metadata`,多租户时带 `user_id`+ 子表 **`secrets`**(每行一个加密字段:`field_name``encrypted`)。另有 `entries_history``secrets_history``audit_log`,以及 **`users`**(含 `key_salt``key_check``key_params``api_key`)、**`oauth_accounts`**。首次连库自动迁移建表。
当前 v3 已切到**零知识同步模型**
| 位置 | 字段 | 说明 |
|------|------|------|
| entries | namespace | 一级隔离,如 `refining``ricnsmart` |
| entries | kind | `server``service``key` 等(可扩展) |
| entries | name | 人类可读标识 |
| entries | metadata | 明文 JSONip、url、`key_ref` 等) |
| secrets | field_name | 明文字段名,便于 schema 展示 |
| secrets | encrypted | AES-GCM 密文(含 nonce |
| users | key_salt | PBKDF2 salt32B首次设置密码短语时写入 |
| users | key_check | 派生密钥加密已知常量,用于验证密码短语 |
| users | key_params | 派生算法参数,如 `{"alg":"pbkdf2-sha256","iterations":600000}` |
- 服务端保存 `vault_objects``vault_object_revisions`
- desktop 本地保存 `vault_objects``vault_object_history``pending_changes``sync_state`
- 搜索、详情、reveal、history 主要在本地已解锁 vault 上完成
- 服务端负责 `auth/device``/sync/`*,不再承担明文搜索与明文 reveal
### PEM 共享(`key_ref`
主要表:
同一 PEM 可被多条 `server` 记录引用:将 PEM 存为 `kind=key` 的 entry在服务器条目的 `metadata.key_ref` 中写 key 的名称;轮换时只更新 key 对应记录即可。
- `users`
- `oauth_accounts`
- `devices`
- `device_login_tokens`
- `auth_events`
- `vault_objects`
- `vault_object_revisions`
## 审计日志
字段职责:
`add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。
其中业务条目事件使用 `[namespace/kind] name` 语义;登录类事件使用 `namespace='auth'`,此时 `kind/name` 表示认证目标(例如 `oauth/google`),不表示某条 secrets entry。
```sql
SELECT action, namespace, kind, name, detail, created_at
FROM audit_log
ORDER BY created_at DESC
LIMIT 20;
```
| 位置 | 字段 | 说明 |
| ------------------------ | ------------------------- | --------------------- |
| `vault_objects` | `object_id` | 同步对象标识 |
| `vault_objects` | `object_kind` | 当前对象类别,当前主要为 `cipher` |
| `vault_objects` | `revision` | 服务端对象版本 |
| `vault_objects` | `ciphertext` | 密文对象载荷 |
| `vault_objects` | `content_hash` | 密文摘要 |
| `vault_objects` | `deleted_at` | 对象级删除标记 |
| `vault_object_revisions` | `revision` / `ciphertext` | 服务端对象历史版本 |
## 认证与事件
当前登录流为 Google Desktop OAuth
- 桌面端使用系统浏览器拉起 Google 授权
- API 服务端负责发起 OAuth、处理 callback、校验 Google userinfo
- desktop 通过创建一次性 login session 并轮询状态获取 `device token`
- 登录与设备活动写入 `auth_events`
## 项目结构
```
```text
Cargo.toml
crates/secrets-core/ # db / crypto / models / audit / service
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
apps/
api/ # 远端 JSON API
desktop/src-tauri/ # Tauri 桌面端
crates/
application/ # v3 应用服务
client-integrations/ # Cursor / Claude Code mcp.json 注入
crypto/ # 通用加密辅助
desktop-daemon/ # 本地 MCP daemon
device-auth/ # Desktop OAuth / device token 辅助
domain/ # 领域模型
infrastructure-db/ # PostgreSQL 连接与迁移
deploy/
.env.example
secrets-mcp.service
postgres-tls-hardening.md
scripts/
deploy/ # systemd、.env 示例
release-check.sh
setup-gitea-actions.sh
```
## CI/CDGitea Actions
见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)
当前以 workspace 级检查为主,见 `[.gitea/workflows/secrets.yml](.gitea/workflows/secrets.yml)`
- **触发**:任意分支 `push`,且变更路径包含 `crates/**``deploy/**`、根目录 `Cargo.toml` / `Cargo.lock`
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → 若 `secrets-mcp-<version>` 的 tag 已存在则**复用现有 tag 继续构建**,否则自动打 tag → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl``secrets-mcp`
- **Release可选**:配置仓库 Secret `RELEASE_TOKEN`Gitea PAT明文勿 base64会通过 API 创建**草稿** Release、在 Linux 构建成功后上传 `tar.gz``.sha256`,再自动将草稿**正式发布**;未配置则跳过创建 Release 与产物上传,仅保留 tag 与构建结果。
- **部署(可选)**:仅在 `main``feat/mcp``mcp` 分支且构建成功时,若已配置 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`,则 `deploy-mcp` 通过 SCP/SSH 更新目标机二进制并 `systemctl restart secrets-mcp`
- **通知(可选)**`vars.WEBHOOK_URL` 为飞书 Webhook 时,构建/部署/发布节点会推送简要状态。
提交前建议直接运行:
```bash
./scripts/setup-gitea-actions.sh # 通过 Gitea API 写入 RELEASE_TOKEN、WEBHOOK_URL、部署相关变量等
./scripts/release-check.sh
```
详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。
详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。

30
apps/api/Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
name = "secrets-api"
version = "0.1.0"
edition.workspace = true
[[bin]]
name = "secrets-api"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
axum.workspace = true
dotenvy.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
chrono.workspace = true
reqwest.workspace = true
sha2.workspace = true
url.workspace = true
base64 = "0.22.1"
secrets-application = { path = "../../crates/application" }
secrets-device-auth = { path = "../../crates/device-auth" }
secrets-domain = { path = "../../crates/domain" }
secrets-infrastructure-db = { path = "../../crates/infrastructure-db" }

View File

@@ -0,0 +1,15 @@
use anyhow::{Context, Result};
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
let database_url = secrets_infrastructure_db::load_database_url()?;
let pool = secrets_infrastructure_db::create_pool(&database_url).await?;
secrets_infrastructure_db::migrate_current_schema(&pool)
.await
.context("failed to initialize current database schema")?;
println!("current database schema initialized");
Ok(())
}

1099
apps/api/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

6
apps/desktop/README.md Normal file
View File

@@ -0,0 +1,6 @@
# apps/desktop
This directory is reserved for the v3 Tauri desktop shell.
The desktop UI is intentionally kept separate from `crates/desktop-daemon` so
that closing the main window does not terminate the local MCP process.

View File

@@ -0,0 +1,208 @@
# Secrets Design System
## 1. Visual Theme & Atmosphere
- Primary inspiration: Raycast desktop UI.
- Secondary influence: Linear information density and list discipline.
- Product personality: secure, local-first, developer-facing, restrained, trustworthy.
- Default mood: dark utility app, not a marketing site and not a glossy consumer app.
- The interface should feel like a native desktop control surface for secrets and MCP integrations.
- Use calm contrast, clean edges, compact spacing, and intentional empty space.
- Prefer precision over decoration. Visual polish should come from alignment, spacing, and hierarchy.
## 2. Color Palette & Roles
### Core Surfaces
- `bg.app`: `#0A0A0B` - app background, deepest canvas.
- `bg.panel`: `#111113` - main panel and modal background.
- `bg.panelElevated`: `#17171A` - cards, selected rows, input shells.
- `bg.panelHover`: `#1D1D22` - hover state for rows and controls.
- `bg.input`: `#141418` - text inputs, code blocks, secret fields.
- `border.subtle`: `#26262C` - default panel borders.
- `border.strong`: `#34343D` - active borders and high-emphasis outlines.
### Text
- `text.primary`: `#F5F5F7` - primary labels and values.
- `text.secondary`: `#B3B3BD` - supporting metadata.
- `text.tertiary`: `#7C7C88` - placeholders and low-emphasis copy.
- `text.inverse`: `#0B0B0D` - text on bright accents.
### Accents
- `accent.blue`: `#3B82F6` - login CTA, toggles, focus ring, trust signals.
- `accent.blueHover`: `#4C8DFF` - hover state for primary interactions.
- `accent.purple`: `#8B5CF6` - secondary accent for selected count pills or light emphasis.
- `accent.amber`: `#D97706` - local warnings or pending states.
- `accent.red`: `#EF4444` - destructive actions.
- `accent.green`: `#22C55E` - success or enabled state when stronger signal is required.
### Semantic Use
- Blue is the main action color. Keep it rare and meaningful.
- Purple can appear in subtle badges or selected-count chips, never as a second primary CTA.
- Red is reserved for delete, revoke, sign-out danger, and destructive confirmations.
- Avoid bright gradients as a dominant surface treatment.
## 3. Typography Rules
- Font stack: `Inter`, `SF Pro Text`, `SF Pro Display`, `Segoe UI`, system sans-serif.
- Use system-friendly text rendering. This is a desktop tool, not a display-heavy website.
- Chinese UI copy is allowed and should feel natural beside English identifiers like `host`, `token`, `MCP`.
- Keep tracking neutral. Avoid wide uppercase spacing except tiny overline labels.
### Type Scale
- App title / page title: 30-34px, weight 700.
- Section title: 18-22px, weight 650-700.
- Card title / row title: 15-17px, weight 600.
- Body text: 13-14px, weight 400-500.
- Caption / metadata label: 11-12px, weight 500, uppercase allowed with modest tracking.
- Monospace values: `SF Mono`, `JetBrains Mono`, `Menlo`, monospace; 12-13px.
## 4. Component Stylings
### App Shell
- Use a three-pane desktop layout for the main screen: left navigation, middle list, right detail pane.
- Pane separation should rely on subtle borders, not strong shadows.
- Sidebar should feel slightly darker than the center list pane.
- The detail pane can be the most open surface, with larger top padding and calmer spacing.
### Login Card
- Centered card on a dark canvas.
- Width: compact, roughly 420-520px.
- Rounded corners: 24-28px.
- Include one lock/trust mark, one clear product title, one short support sentence, one primary Google login button.
- Login should feel calm and premium, never busy.
### Buttons
- Primary button: dark app shell with blue fill, white text, medium radius.
- Secondary button: dark raised surface with subtle border.
- Destructive button: same structure as secondary, with red text or red-emphasis border only when needed.
- Button height should feel desktop-like, not mobile oversized.
- Avoid flashy gradients and oversized glows.
### Inputs
- Inputs use dark filled surfaces, subtle inset feel, 12-14px radius.
- Border should be nearly invisible at rest and stronger on hover/focus.
- Placeholders should be quiet and low-contrast.
- Search and filter inputs should visually align and share the same height.
### Lists and Rows
- Entry rows should be compact, crisp, and easy to scan.
- Selected row: slightly brighter dark card, subtle border, no heavy glow.
- Support a two-line rhythm: primary name and smaller type/folder metadata.
- Counts in the sidebar should use muted rounded chips.
### Detail Pane
- Use strong top title hierarchy with restrained action buttons on the right.
- Metadata should be presented in structured blocks or columns, not loose paragraphs.
- Secret values should live inside dedicated protected field cards.
- Secret field rows should include icon, masked value, reveal action, and copy action.
- Sensitive content must look controlled and deliberate, not playful.
### Modals
- Modal cards should feel like elevated control panels.
- MCP integration modal should support stacked integration rows with trailing toggles.
- Embedded JSON/config blocks should use a darker, code-oriented surface with monospace text.
- Large modal width is acceptable for configuration-heavy content.
### Toggles
- Use blue enabled state by default.
- Toggle track should be compact and clean, avoiding iOS-like softness.
- Align toggles flush right in integration lists.
### Badges and Status Pills
- Use small rounded pills for folder counts, archived state, or recent-delete state.
- Prefer muted purple, gray, or amber fills over saturated color blocks.
## 5. Layout Principles
- Use an 8px spacing system.
- Typical paddings:
- Sidebars: 16-20px.
- List and toolbar: 12-18px.
- Detail pane: 24-32px.
- Modals: 20-28px.
- Favor even vertical rhythm over decorative separators.
- Keep left edges aligned aggressively across sections.
- Avoid oversized hero spacing inside application surfaces.
- The main app should feel dense enough for productivity but never cramped.
## 6. Depth & Elevation
- Most separation should come from tone shifts and borders.
- Base panels: no shadow or extremely soft shadow.
- Elevated cards and modals: subtle shadow only, with low blur and low opacity.
- Do not use neon bloom, oversized backdrop blur, or glassmorphism.
- Focus states should use border color and a faint blue outer ring.
## 7. Do's and Don'ts
### Do
- Keep the UI dark, crisp, and desktop-native.
- Preserve strong information hierarchy in the detail pane.
- Make security-sensitive actions feel explicit and carefully gated.
- Use compact controls and disciplined spacing.
- Let alignment and typography carry most of the visual quality.
- Keep MCP integration screens structured like settings panels.
### Don't
- Do not turn the app into a landing page aesthetic.
- Do not use giant gradients, colorful illustrations, or soft SaaS cards.
- Do not over-round every surface.
- Do not mix many accent colors in one screen.
- Do not make secret fields look like casual form inputs.
- Do not use bright white backgrounds in the desktop app.
## 8. Responsive Behavior
- Primary target is desktop widths from 1280px upward.
- The three-pane shell should remain stable on desktop.
- At narrower widths, collapse from three panes to two panes before using stacked mobile behavior.
- The MCP modal can reduce width but should keep readable row spacing and code block legibility.
- Buttons and toggles should remain mouse-first, with minimum 32px touch-friendly height where practical.
## 9. Screen-Specific Guidance
### Login Screen
- Centered trust card.
- One focal icon or emblem above the title.
- Keep copy short.
- The Google login button should be the visual anchor.
### Main Secrets Screen
- Left sidebar: user card, folder navigation, utility actions near the bottom.
- Middle pane: search, type filter, result list.
- Right pane: selected entry title, metadata grid, secret cards, edit actions.
- The selected item should be immediately obvious but understated.
### MCP Integration Screen
- Treat as a settings modal.
- Integration rows should read like desktop preferences, not marketing feature cards.
- JSON config block should feel developer-native and copy-friendly.
## 10. Agent Prompt Guide
- Keywords: `dark desktop utility`, `Raycast-inspired`, `Linear-density`, `secure control panel`, `developer tool`, `restrained premium`, `MCP settings modal`.
- When generating screens, preserve: dark surfaces, subtle borders, compact controls, right-aligned actions, clean typography, muted status pills.
- If unsure, bias toward less decoration and tighter structure.
## 11. Quick Summary for Agents
Build Secrets like a polished desktop utility: mostly Raycast in atmosphere, a little Linear in density, with dark layered panels, precise typography, subtle borders, blue-only primary actions, and security-sensitive detail cards that feel calm, serious, and highly usable.

File diff suppressed because it is too large Load Diff

41
apps/desktop/dist/disable-features.js vendored Normal file
View File

@@ -0,0 +1,41 @@
(() => {
const tauriInvoke = window.__TAURI_INTERNALS__?.invoke;
// Disable text selection globally, but keep inputs editable.
document.addEventListener("selectstart", (event) => {
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
return;
}
event.preventDefault();
});
async function applyProductionGuards() {
if (!tauriInvoke) {
return;
}
let isDebugBuild = false;
try {
isDebugBuild = await tauriInvoke("is_debug_build");
} catch {
return;
}
if (isDebugBuild) {
return;
}
document.addEventListener("contextmenu", (event) => event.preventDefault());
document.addEventListener("keydown", (event) => {
if (event.key === "F12") {
event.preventDefault();
}
if ((event.ctrlKey || event.metaKey) && event.shiftKey && ["I", "C", "J"].includes(event.key.toUpperCase())) {
event.preventDefault();
}
});
}
void applyProductionGuards();
})();

BIN
apps/desktop/dist/favicon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

279
apps/desktop/dist/index.html vendored Normal file
View File

@@ -0,0 +1,279 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Secrets</title>
<link rel="stylesheet" href="./styles.css" />
<script src="./disable-features.js"></script>
</head>
<body>
<div id="login-view" class="login-screen hidden">
<div class="window-titlebar login-titlebar" data-tauri-drag-region aria-hidden="true"></div>
<div class="login-card">
<div class="login-main">
<div class="login-emblem" aria-hidden="true">
<svg class="login-lock-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="16" r="1"></circle>
<rect x="3" y="10" width="18" height="12" rx="2"></rect>
<path d="M7 10V7a5 5 0 0 1 10 0v3"></path>
</svg>
</div>
<div class="login-title-block">
<h1>Secrets</h1>
<p class="login-subtle">用 AI 安全地管理和使用密钥</p>
</div>
<div class="login-actions">
<button id="login-button" class="primary login-google-button">
<svg class="login-google-mark" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
<span>前往浏览器登录</span>
</button>
</div>
<p id="login-error" class="error-text hidden"></p>
</div>
</div>
</div>
<div id="vault-modal" class="modal hidden">
<div class="modal-card">
<div class="modal-header">
<h3 id="vault-modal-title">解锁本地 Vault</h3>
</div>
<p id="vault-modal-copy" class="subtle modal-copy">请输入本地 vault 主密码。</p>
<div class="modal-form">
<label class="field-label">
<span>主密码</span>
<input id="vault-password-input" type="password" class="detail-input" placeholder="输入主密码" />
</label>
</div>
<p id="vault-modal-error" class="error-text hidden"></p>
<div class="modal-actions">
<button id="vault-modal-save" class="primary small">继续</button>
</div>
</div>
</div>
<div id="app-shell" class="shell hidden">
<div class="window-titlebar shell-titlebar" data-tauri-drag-region aria-hidden="true"></div>
<aside class="sidebar">
<div class="user-block">
<button id="user-trigger" class="user-trigger">
<div class="avatar">V</div>
<div class="user-copy">
<div id="user-name" class="user-name">-</div>
<div id="user-email" class="user-email">-</div>
</div>
<span class="caret"></span>
</button>
<div id="user-menu" class="user-menu hidden">
<button id="manage-devices" class="menu-item">管理设备</button>
</div>
</div>
<div id="folder-list" class="folder-list"></div>
<div class="sidebar-spacer"></div>
<div class="sidebar-footer">
<button id="open-mcp-modal" class="sidebar-utility">
<span class="sidebar-utility-icon" aria-hidden="true"></span>
<span>MCP</span>
</button>
<button id="logout-button" class="sidebar-utility">
<span class="sidebar-utility-icon" aria-hidden="true"></span>
<span>退出登录</span>
</button>
</div>
</aside>
<main class="main-shell">
<section class="list-column">
<div class="searchbar-shell">
<input id="search-input" class="search-input global-search" placeholder="按名称模糊搜索" />
</div>
<section class="list-pane">
<div class="toolbar">
<button id="new-entry-button" class="secondary-button small">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">新建条目</span>
</button>
<select id="type-filter" class="filter-select">
<option value="">全部类型</option>
</select>
</div>
<div id="entry-list" class="entry-list"></div>
</section>
</section>
<section class="detail-pane">
<div class="detail-header">
<div class="detail-title-stack">
<div id="detail-folder-label" class="detail-folder-label">-</div>
<div class="detail-title-block">
<h2 id="entry-title">-</h2>
<div id="detail-badge" class="detail-badge hidden">最近删除</div>
</div>
</div>
<div class="detail-actions">
<button id="edit-entry-button" class="secondary-button small action-button">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">编辑</span>
</button>
<button id="delete-entry-button" class="secondary-button small danger action-button hidden">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">删除</span>
</button>
<button id="restore-entry-button" class="secondary-button small action-button hidden">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">恢复</span>
</button>
<button id="save-entry-button" class="primary small action-button hidden">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">保存</span>
</button>
<button id="cancel-edit-button" class="secondary-button small action-button hidden">
<span class="button-icon" aria-hidden="true">×</span>
<span class="button-label">取消</span>
</button>
</div>
</div>
<div id="name-section" class="detail-section detail-edit-section hidden">
<h3>名称</h3>
<div id="name-view" class="detail-inline-value">-</div>
<input id="name-input" class="detail-input hidden" />
</div>
<div class="detail-section">
<h3>元数据</h3>
<div id="metadata-list" class="detail-fields"></div>
<div id="metadata-editor" class="metadata-editor hidden"></div>
<button id="add-metadata-button" class="secondary-button small hidden">新增元数据</button>
</div>
<div class="detail-section">
<div class="section-header-row">
<h3>密钥</h3>
<button id="add-secret-button" class="secondary-button small hidden">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">新增密钥</span>
</button>
</div>
<div id="secret-list" class="secret-list"></div>
</div>
</section>
</main>
<div id="device-modal" class="modal hidden">
<div class="modal-card">
<div class="modal-header">
<h3>设备在线列表</h3>
<button id="close-device-modal" class="icon-button">×</button>
</div>
<p class="subtle modal-copy">查看已登录设备的在线情况与最近活动。</p>
<div id="device-list" class="device-list"></div>
</div>
</div>
<div id="mcp-modal" class="modal hidden">
<div class="modal-card wide">
<div class="modal-header">
<h3>MCP 集成</h3>
<button id="close-mcp-modal" class="icon-button">×</button>
</div>
<p class="subtle modal-copy">查看当前 AI 工具的 MCP 集成情况,并一键写入本地 daemon 配置。</p>
<section class="modal-section">
<div id="mcp-integration-list" class="integration-list"></div>
<p class="modal-footnote">启动 Secrets 桌面端时,可按选择自动为上述工具写入 MCP 配置。</p>
</section>
<section class="detail-section compact modal-section">
<div class="mcp-json-header">
<h4>自定义 MCP 配置</h4>
<button id="copy-mcp-config" class="secondary-button small">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">复制</span>
</button>
</div>
<pre id="mcp-config" class="mcp-config"></pre>
</section>
</div>
</div>
<div id="entry-modal" class="modal hidden">
<div class="modal-card">
<div class="modal-header">
<h3>新建条目</h3>
<button id="close-entry-modal" class="icon-button">×</button>
</div>
<div class="modal-form">
<label class="field-label">
<span>项目</span>
<input id="entry-modal-folder" class="detail-input" placeholder="例如Refining" />
</label>
<label class="field-label">
<span>名称</span>
<input id="entry-modal-title" class="detail-input" placeholder="例如secrets-local" />
</label>
<label class="field-label">
<span>类型</span>
<input id="entry-modal-type" class="detail-input" placeholder="例如service" />
</label>
</div>
<div class="modal-actions">
<button id="entry-modal-cancel" class="secondary-button small">取消</button>
<button id="entry-modal-save" class="primary small">创建</button>
</div>
</div>
</div>
<div id="secret-modal" class="modal hidden">
<div class="modal-card">
<div class="modal-header">
<h3 id="secret-modal-title">新增密钥</h3>
<button id="close-secret-modal" class="icon-button">×</button>
</div>
<div class="modal-form">
<label class="field-label">
<span>名称</span>
<input id="secret-name-input" class="detail-input" placeholder="例如token" />
</label>
<label class="field-label">
<span>类型</span>
<select id="secret-type-input" class="filter-select">
<option value="text">text</option>
<option value="password">password</option>
<option value="key">key</option>
</select>
</label>
<label class="field-label">
<span>内容</span>
<textarea id="secret-value-input" class="detail-input detail-textarea" placeholder="输入密钥内容"></textarea>
</label>
</div>
<div class="modal-actions">
<button id="secret-modal-cancel" class="secondary-button small">取消</button>
<button id="secret-modal-save" class="primary small">保存</button>
</div>
</div>
</div>
<div id="history-modal" class="modal hidden">
<div class="modal-card wide">
<div class="modal-header">
<h3>密钥历史</h3>
<button id="close-history-modal" class="icon-button">×</button>
</div>
<p id="history-modal-copy" class="subtle modal-copy">查看版本历史并回滚到指定版本。</p>
<div id="history-list" class="history-list"></div>
</div>
</div>
</div>
<script src="./main.js"></script>
</body>
</html>

1020
apps/desktop/dist/main.js vendored Normal file

File diff suppressed because it is too large Load Diff

1072
apps/desktop/dist/styles.css vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
[package]
name = "secrets-desktop"
version = "3.0.0"
edition.workspace = true
[build-dependencies]
tauri-build.workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
chrono.workspace = true
hex.workspace = true
sqlx.workspace = true
serde.workspace = true
serde_json.workspace = true
tauri.workspace = true
tokio.workspace = true
reqwest.workspace = true
sha2.workspace = true
url.workspace = true
uuid.workspace = true
base64 = "0.22.1"
secrets-client-integrations = { path = "../../../crates/client-integrations" }
secrets-crypto = { path = "../../../crates/crypto" }
secrets-device-auth = { path = "../../../crates/device-auth" }
secrets-domain = { path = "../../../crates/domain" }
[[bin]]
name = "Secrets"
path = "src/main.rs"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,2 @@
const fs = require('fs');
// Very simple check: read the first few bytes, maybe we can use an image library to find the bounding box

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
use anyhow::{Context, Result as AnyResult};
use axum::{
Router,
body::{Body, to_bytes},
extract::{Request, State as AxumState},
http::{StatusCode as AxumStatusCode, header},
response::Response,
routing::{any, get, post},
};
use url::Url;
use crate::local_vault::{
LocalEntryQuery, bootstrap as vault_bootstrap, create_entry as vault_create_entry,
create_secret as vault_create_secret, delete_entry as vault_delete_entry,
delete_secret as vault_delete_secret, entry_detail as vault_entry_detail,
list_entries as vault_list_entries, restore_entry as vault_restore_entry,
reveal_secret_value as vault_reveal_secret_value, rollback_secret as vault_rollback_secret,
secret_history as vault_secret_history, update_entry as vault_update_entry,
update_secret as vault_update_secret,
};
use crate::{
DesktopState, EntryDetail, EntryDraft, EntryListItem, EntryListQuery, SecretDraft,
SecretUpdateDraft, current_device_token, map_entry_detail_to_local, map_entry_draft_to_local,
map_local_entry_detail, map_local_history_item, map_local_secret_value,
map_secret_draft_to_local, map_secret_update_to_local, split_secret_ref_for_ui,
sync_local_vault,
};
pub async fn desktop_session_health(
AxumState(state): AxumState<DesktopState>,
) -> Result<&'static str, AxumStatusCode> {
current_device_token(&state)
.map(|_| "ok")
.map_err(|_| AxumStatusCode::UNAUTHORIZED)
}
pub async fn desktop_session_api(
AxumState(state): AxumState<DesktopState>,
request: Request<Body>,
) -> Response {
let (parts, body) = request.into_parts();
let path_and_query = parts
.uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let body_bytes = match to_bytes(body, 1024 * 1024).await {
Ok(bytes) => bytes,
Err(_) => {
return Response::builder()
.status(AxumStatusCode::BAD_REQUEST)
.body(Body::from("failed to read relay request body"))
.expect("build relay bad request");
}
};
handle_local_session_request(&state, parts.method.as_str(), path_and_query, &body_bytes)
.await
.unwrap_or_else(|| {
Response::builder()
.status(AxumStatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(
r#"{"error":"desktop local vault route not found"}"#,
))
.expect("build local session not found response")
})
}
async fn handle_local_session_request(
state: &DesktopState,
method: &str,
path_and_query: &str,
body_bytes: &[u8],
) -> Option<Response> {
let path = path_and_query.split('?').next().unwrap_or(path_and_query);
let make_json = |status: AxumStatusCode, value: serde_json::Value| {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(value.to_string()))
.expect("build local session response")
};
match (method, path) {
("GET", "/vault/status") => {
let status = vault_bootstrap(&state.local_vault).await.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({
"unlocked": status.unlocked,
"has_master_password": status.has_master_password
}),
))
}
("GET", "/vault/entries") => {
let url = format!("http://localhost{path_and_query}");
let parsed = Url::parse(&url).ok()?;
let mut query = EntryListQuery {
folder: None,
entry_type: None,
query: None,
deleted_only: false,
};
for (key, value) in parsed.query_pairs() {
match key.as_ref() {
"folder" => query.folder = Some(value.into_owned()),
"entry_type" => query.entry_type = Some(value.into_owned()),
"query" => query.query = Some(value.into_owned()),
"deleted_only" => query.deleted_only = value == "true",
_ => {}
}
}
let entries = vault_list_entries(
&state.local_vault,
&LocalEntryQuery {
folder: query.folder,
cipher_type: query.entry_type,
query: query.query,
deleted_only: query.deleted_only,
},
)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(
entries
.into_iter()
.map(|entry| EntryListItem {
id: entry.id,
title: entry.name,
subtitle: entry.cipher_type,
folder: entry.folder,
deleted: entry.deleted,
})
.collect::<Vec<_>>(),
)
.ok()?,
))
}
_ if method == "GET" && path.starts_with("/vault/entries/") => {
let entry_id = path.trim_start_matches("/vault/entries/");
let detail = vault_entry_detail(&state.local_vault, entry_id)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(detail)).ok()?,
))
}
("POST", "/vault/entries") => {
let draft: EntryDraft = serde_json::from_slice(body_bytes).ok()?;
let created = vault_create_entry(&state.local_vault, map_entry_draft_to_local(draft))
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(created)).ok()?,
))
}
_ if method == "PATCH" && path.starts_with("/vault/entries/") => {
let entry_id = path.trim_start_matches("/vault/entries/").to_string();
let mut detail: EntryDetail = serde_json::from_slice(body_bytes).ok()?;
detail.id = entry_id;
let updated = vault_update_entry(&state.local_vault, map_entry_detail_to_local(detail))
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ if method == "POST"
&& path.starts_with("/vault/entries/")
&& path.ends_with("/delete") =>
{
let entry_id = path
.trim_start_matches("/vault/entries/")
.trim_end_matches("/delete")
.trim_end_matches('/');
vault_delete_entry(&state.local_vault, entry_id)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({ "ok": true }),
))
}
_ if method == "POST"
&& path.starts_with("/vault/entries/")
&& path.ends_with("/restore") =>
{
let entry_id = path
.trim_start_matches("/vault/entries/")
.trim_end_matches("/restore")
.trim_end_matches('/');
vault_restore_entry(&state.local_vault, entry_id)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({ "ok": true }),
))
}
_ if method == "POST"
&& path.starts_with("/vault/entries/")
&& path.ends_with("/secrets") =>
{
let entry_id = path
.trim_start_matches("/vault/entries/")
.trim_end_matches("/secrets")
.trim_end_matches('/');
let secret: SecretDraft = serde_json::from_slice(body_bytes).ok()?;
let updated = vault_create_secret(
&state.local_vault,
entry_id,
map_secret_draft_to_local(secret),
)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ if method == "GET" && path.starts_with("/vault/secrets/") && path.ends_with("/value") => {
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/value")
.trim_end_matches('/')
.to_string();
let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?;
let value = vault_reveal_secret_value(&state.local_vault, &entry_id, &secret_name)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_secret_value(value)).ok()?,
))
}
_ if method == "GET"
&& path.starts_with("/vault/secrets/")
&& path.ends_with("/history") =>
{
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/history")
.trim_end_matches('/')
.to_string();
let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?;
let history = vault_secret_history(&state.local_vault, &entry_id, &secret_name)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(
history
.into_iter()
.map(map_local_history_item)
.collect::<Vec<_>>(),
)
.ok()?,
))
}
_ if method == "PATCH" && path.starts_with("/vault/secrets/") => {
let secret_id = path.trim_start_matches("/vault/secrets/").to_string();
let mut update: SecretUpdateDraft = serde_json::from_slice(body_bytes).ok()?;
update.id = secret_id;
let updated =
vault_update_secret(&state.local_vault, map_secret_update_to_local(update))
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ if method == "POST"
&& path.starts_with("/vault/secrets/")
&& path.ends_with("/delete") =>
{
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/delete")
.trim_end_matches('/');
vault_delete_secret(&state.local_vault, secret_id)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({ "ok": true }),
))
}
_ if method == "POST"
&& path.starts_with("/vault/secrets/")
&& path.ends_with("/rollback") =>
{
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/rollback")
.trim_end_matches('/')
.to_string();
let payload: serde_json::Value = serde_json::from_slice(body_bytes).ok()?;
let updated = vault_rollback_secret(
&state.local_vault,
&secret_id,
payload.get("history_id").and_then(|value| value.as_i64()),
)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ => None,
}
}
pub async fn start_desktop_session_server(state: DesktopState) -> AnyResult<()> {
let app = Router::new()
.route("/healthz", get(desktop_session_health))
.route("/vault/status", get(desktop_session_api))
.route("/vault/entries", any(desktop_session_api))
.route("/vault/entries/{id}", any(desktop_session_api))
.route("/vault/entries/{id}/delete", post(desktop_session_api))
.route("/vault/entries/{id}/restore", post(desktop_session_api))
.route("/vault/entries/{id}/secrets", post(desktop_session_api))
.route("/vault/secrets/{id}", any(desktop_session_api))
.route("/vault/secrets/{id}/value", get(desktop_session_api))
.route("/vault/secrets/{id}/history", get(desktop_session_api))
.route("/vault/secrets/{id}/delete", post(desktop_session_api))
.route("/vault/secrets/{id}/rollback", post(desktop_session_api))
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind(&state.session_bind)
.await
.with_context(|| {
format!(
"failed to bind desktop session relay {}",
state.session_bind
)
})?;
axum::serve(listener, app)
.await
.context("desktop session relay server error")
}

View File

@@ -0,0 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Secrets",
"version": "3.0.0",
"identifier": "dev.refining.secrets",
"build": {
"beforeDevCommand": "",
"beforeBuildCommand": "",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Secrets",
"width": 420,
"height": 400,
"minWidth": 420,
"minHeight": 400,
"resizable": true,
"titleBarStyle": "overlay",
"hiddenTitle": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "secrets-application"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_application"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
uuid.workspace = true
secrets-domain = { path = "../domain" }

View File

@@ -0,0 +1,9 @@
use secrets_domain::VaultObjectEnvelope;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct RevisionConflict {
pub change_id: Uuid,
pub object_id: Uuid,
pub server_object: Option<VaultObjectEnvelope>,
}

View File

@@ -0,0 +1,3 @@
pub mod conflict;
pub mod sync;
pub mod vault_store;

View File

@@ -0,0 +1,252 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use secrets_domain::{
SyncAcceptedChange, SyncConflict, SyncPullRequest, SyncPullResponse, SyncPushRequest,
SyncPushResponse, VaultObjectChange, VaultObjectEnvelope,
};
use crate::vault_store::{
get_object, list_objects_since, list_tombstones_since, max_server_revision,
};
fn detect_conflict(
change: &VaultObjectChange,
existing: Option<&VaultObjectEnvelope>,
) -> Option<SyncConflict> {
match (change.base_revision, existing) {
(Some(base_revision), Some(server_object)) if server_object.revision != base_revision => {
Some(SyncConflict {
change_id: change.change_id,
object_id: change.object_id,
reason: "revision_conflict".to_string(),
server_object: Some(server_object.clone()),
})
}
_ if !matches!(change.operation.as_str(), "upsert" | "delete") => Some(SyncConflict {
change_id: change.change_id,
object_id: change.object_id,
reason: "unsupported_operation".to_string(),
server_object: existing.cloned(),
}),
_ => None,
}
}
pub async fn sync_pull(
pool: &PgPool,
user_id: Uuid,
request: SyncPullRequest,
) -> Result<SyncPullResponse> {
let cursor = request.cursor.unwrap_or(0).max(0);
let limit = request.limit.unwrap_or(200).clamp(1, 500);
let objects = list_objects_since(pool, user_id, cursor, limit).await?;
let tombstones = if request.include_deleted {
list_tombstones_since(pool, user_id, cursor, limit).await?
} else {
Vec::new()
};
let server_revision = max_server_revision(pool, user_id).await?;
let next_cursor = objects
.last()
.map(|object| object.revision)
.unwrap_or(cursor);
Ok(SyncPullResponse {
server_revision,
next_cursor,
has_more: (objects.len() as i64) >= limit,
objects,
tombstones,
})
}
pub async fn sync_push(
pool: &PgPool,
user_id: Uuid,
request: SyncPushRequest,
) -> Result<SyncPushResponse> {
let mut accepted = Vec::new();
let mut conflicts = Vec::new();
for change in request.changes {
let existing = get_object(pool, user_id, change.object_id).await?;
if let Some(conflict) = detect_conflict(&change, existing.as_ref()) {
conflicts.push(conflict);
continue;
}
let next_revision = existing
.as_ref()
.map(|object| object.revision + 1)
.unwrap_or(1);
let next_cipher_version = change.cipher_version.unwrap_or(1);
let next_ciphertext = change.ciphertext.clone().unwrap_or_default();
let next_content_hash = change.content_hash.clone().unwrap_or_default();
let next_deleted_at = if change.operation == "delete" {
Some(chrono::Utc::now())
} else {
None
};
match change.operation.as_str() {
"upsert" => {
sqlx::query(
r#"
INSERT INTO vault_objects (
object_id, user_id, object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at, created_by_device
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, NOW(), NULL)
ON CONFLICT (object_id)
DO UPDATE SET
revision = EXCLUDED.revision,
cipher_version = EXCLUDED.cipher_version,
ciphertext = EXCLUDED.ciphertext,
content_hash = EXCLUDED.content_hash,
deleted_at = NULL,
updated_at = NOW()
"#,
)
.bind(change.object_id)
.bind(user_id)
.bind(change.object_kind.as_str())
.bind(next_revision)
.bind(next_cipher_version)
.bind(next_ciphertext.clone())
.bind(next_content_hash.clone())
.execute(pool)
.await?;
}
"delete" => {
sqlx::query(
r#"
UPDATE vault_objects
SET revision = $1, deleted_at = NOW(), updated_at = NOW()
WHERE object_id = $2
AND user_id = $3
"#,
)
.bind(next_revision)
.bind(change.object_id)
.bind(user_id)
.execute(pool)
.await?;
}
_ => unreachable!("unsupported operations are filtered by detect_conflict"),
}
sqlx::query(
r#"
INSERT INTO vault_object_revisions (
object_id, user_id, revision, cipher_version, ciphertext, content_hash, deleted_at, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
"#,
)
.bind(change.object_id)
.bind(user_id)
.bind(next_revision)
.bind(next_cipher_version)
.bind(next_ciphertext)
.bind(next_content_hash)
.bind(next_deleted_at)
.execute(pool)
.await?;
accepted.push(SyncAcceptedChange {
change_id: change.change_id,
object_id: change.object_id,
revision: next_revision,
});
}
let server_revision = max_server_revision(pool, user_id).await?;
Ok(SyncPushResponse {
server_revision,
accepted,
conflicts,
})
}
pub async fn fetch_object(
pool: &PgPool,
user_id: Uuid,
object_id: Uuid,
) -> Result<Option<VaultObjectEnvelope>> {
get_object(pool, user_id, object_id).await
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use secrets_domain::{VaultObjectChange, VaultObjectKind};
use uuid::Uuid;
fn sample_change(operation: &str, base_revision: Option<i64>) -> VaultObjectChange {
VaultObjectChange {
change_id: Uuid::nil(),
object_id: Uuid::max(),
object_kind: VaultObjectKind::Cipher,
operation: operation.to_string(),
base_revision,
cipher_version: Some(1),
ciphertext: Some(vec![1, 2, 3]),
content_hash: Some("sha256:test".to_string()),
}
}
fn sample_object(revision: i64) -> VaultObjectEnvelope {
VaultObjectEnvelope {
object_id: Uuid::max(),
object_kind: VaultObjectKind::Cipher,
revision,
cipher_version: 1,
ciphertext: vec![9, 9, 9],
content_hash: "sha256:server".to_string(),
deleted_at: None,
updated_at: Utc::now(),
}
}
#[test]
fn conflict_when_base_revision_is_stale() {
let mut change = sample_change("upsert", Some(3));
let server = sample_object(5);
change.object_id = server.object_id;
let conflict = detect_conflict(&change, Some(&server)).expect("expected conflict");
assert_eq!(conflict.reason, "revision_conflict");
assert_eq!(conflict.object_id, server.object_id);
assert_eq!(
conflict
.server_object
.as_ref()
.map(|object| object.revision),
Some(5)
);
}
#[test]
fn no_conflict_when_revision_matches() {
let mut change = sample_change("upsert", Some(5));
let server = sample_object(5);
change.object_id = server.object_id;
let conflict = detect_conflict(&change, Some(&server));
assert!(conflict.is_none());
}
#[test]
fn unsupported_operation_is_conflict() {
let change = sample_change("merge", None);
let conflict = detect_conflict(&change, None).expect("expected unsupported operation");
assert_eq!(conflict.reason, "unsupported_operation");
assert!(conflict.server_object.is_none());
}
}

View File

@@ -0,0 +1,147 @@
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use secrets_domain::{VaultObjectEnvelope, VaultObjectKind, VaultTombstone};
#[derive(Debug, sqlx::FromRow)]
struct VaultObjectRow {
object_id: Uuid,
_object_kind: String,
revision: i64,
cipher_version: i32,
ciphertext: Vec<u8>,
content_hash: String,
deleted_at: Option<DateTime<Utc>>,
updated_at: DateTime<Utc>,
}
impl From<VaultObjectRow> for VaultObjectEnvelope {
fn from(row: VaultObjectRow) -> Self {
Self {
object_id: row.object_id,
object_kind: VaultObjectKind::Cipher,
revision: row.revision,
cipher_version: row.cipher_version,
ciphertext: row.ciphertext,
content_hash: row.content_hash,
deleted_at: row.deleted_at,
updated_at: row.updated_at,
}
}
}
pub async fn list_objects_since(
pool: &PgPool,
user_id: Uuid,
cursor: i64,
limit: i64,
) -> Result<Vec<VaultObjectEnvelope>> {
let rows = sqlx::query_as::<_, VaultObjectRow>(
r#"
SELECT
object_id,
object_kind AS _object_kind,
revision,
cipher_version,
ciphertext,
content_hash,
deleted_at,
updated_at
FROM vault_objects
WHERE user_id = $1
AND revision > $2
ORDER BY revision ASC
LIMIT $3
"#,
)
.bind(user_id)
.bind(cursor)
.bind(limit.max(1))
.fetch_all(pool)
.await
.context("failed to list vault objects")?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn get_object(
pool: &PgPool,
user_id: Uuid,
object_id: Uuid,
) -> Result<Option<VaultObjectEnvelope>> {
let row = sqlx::query_as::<_, VaultObjectRow>(
r#"
SELECT
object_id,
object_kind AS _object_kind,
revision,
cipher_version,
ciphertext,
content_hash,
deleted_at,
updated_at
FROM vault_objects
WHERE user_id = $1
AND object_id = $2
"#,
)
.bind(user_id)
.bind(object_id)
.fetch_optional(pool)
.await
.context("failed to load vault object")?;
Ok(row.map(Into::into))
}
pub async fn list_tombstones_since(
pool: &PgPool,
user_id: Uuid,
cursor: i64,
limit: i64,
) -> Result<Vec<VaultTombstone>> {
let rows = sqlx::query_as::<_, (Uuid, i64, DateTime<Utc>)>(
r#"
SELECT object_id, revision, deleted_at
FROM vault_objects
WHERE user_id = $1
AND revision > $2
AND deleted_at IS NOT NULL
ORDER BY revision ASC
LIMIT $3
"#,
)
.bind(user_id)
.bind(cursor)
.bind(limit.max(1))
.fetch_all(pool)
.await
.context("failed to list tombstones")?;
Ok(rows
.into_iter()
.map(|(object_id, revision, deleted_at)| VaultTombstone {
object_id,
revision,
deleted_at,
})
.collect())
}
pub async fn max_server_revision(pool: &PgPool, user_id: Uuid) -> Result<i64> {
let revision = sqlx::query_scalar::<_, Option<i64>>(
r#"
SELECT MAX(revision)
FROM vault_objects
WHERE user_id = $1
"#,
)
.bind(user_id)
.fetch_one(pool)
.await
.context("failed to load max server revision")?;
Ok(revision.unwrap_or(0))
}

View File

@@ -0,0 +1,13 @@
[package]
name = "secrets-client-integrations"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_client_integrations"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -0,0 +1,162 @@
use anyhow::{Context, Result};
use serde_json::{Map, Value};
use std::{
fs,
path::{Path, PathBuf},
};
pub trait ClientAdapter {
fn client_name(&self) -> &'static str;
fn config_path(&self) -> PathBuf;
}
pub struct CursorAdapter;
impl ClientAdapter for CursorAdapter {
fn client_name(&self) -> &'static str {
"cursor"
}
fn config_path(&self) -> PathBuf {
default_home().join(".cursor").join("mcp.json")
}
}
pub struct ClaudeCodeAdapter;
impl ClientAdapter for ClaudeCodeAdapter {
fn client_name(&self) -> &'static str {
"claude-code"
}
fn config_path(&self) -> PathBuf {
default_home().join(".claude").join("mcp.json")
}
}
fn default_home() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn has_managed_server(adapter: &dyn ClientAdapter, server_name: &str) -> Result<bool> {
let path = adapter.config_path();
let root = read_config_or_default(&path)?;
Ok(root
.get("mcpServers")
.and_then(Value::as_object)
.is_some_and(|servers| servers.contains_key(server_name)))
}
pub fn upsert_managed_server(
adapter: &dyn ClientAdapter,
server_name: &str,
server_config: Value,
) -> Result<()> {
let path = adapter.config_path();
let mut root = read_config_or_default(&path)?;
let root_object = ensure_object(&mut root);
let mcp_servers = root_object
.entry("mcpServers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
let servers_object = ensure_object(mcp_servers);
servers_object.insert(server_name.to_string(), server_config);
write_config_atomically(&path, &root)
}
fn read_config_or_default(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(Value::Object(Map::new()));
}
let raw =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
}
fn write_config_atomically(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let tmp_path = path.with_extension("json.tmp");
let body = serde_json::to_string_pretty(value).context("failed to serialize mcp config")?;
fs::write(&tmp_path, body)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
fs::rename(&tmp_path, path).with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}
fn ensure_object(value: &mut Value) -> &mut Map<String, Value> {
if !value.is_object() {
*value = Value::Object(Map::new());
}
value.as_object_mut().expect("object just ensured")
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct TestAdapter {
path: PathBuf,
}
impl ClientAdapter for TestAdapter {
fn client_name(&self) -> &'static str {
"test"
}
fn config_path(&self) -> PathBuf {
self.path.clone()
}
}
#[test]
fn upsert_preserves_other_servers() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let base = std::env::temp_dir().join(format!("secrets-client-integrations-{unique}"));
let adapter = TestAdapter {
path: base.join("mcp.json"),
};
fs::create_dir_all(adapter.path.parent().expect("parent")).expect("mkdir");
fs::write(
&adapter.path,
r#"{"mcpServers":{"postgres":{"command":"npx"},"secrets":{"url":"http://old"}}}"#,
)
.expect("seed config");
upsert_managed_server(
&adapter,
"secrets",
serde_json::json!({
"url": "http://127.0.0.1:9515/mcp"
}),
)
.expect("upsert config");
let root: Value =
serde_json::from_str(&fs::read_to_string(&adapter.path).expect("read back"))
.expect("parse back");
let servers = root
.get("mcpServers")
.and_then(Value::as_object)
.expect("mcpServers object");
assert!(servers.contains_key("postgres"));
assert_eq!(
servers
.get("secrets")
.and_then(Value::as_object)
.and_then(|value| value.get("url"))
.and_then(Value::as_str),
Some("http://127.0.0.1:9515/mcp")
);
let _ = fs::remove_dir_all(base);
}
}

14
crates/crypto/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "secrets-crypto"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_crypto"
path = "src/lib.rs"
[dependencies]
aes-gcm.workspace = true
anyhow.workspace = true
hex.workspace = true
rand.workspace = true

47
crates/crypto/src/lib.rs Normal file
View File

@@ -0,0 +1,47 @@
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use anyhow::{Context, Result};
use rand::Rng;
pub const KEY_CHECK_PLAINTEXT: &[u8] = b"secrets-v3-key-check";
pub fn decode_hex(input: &str) -> Result<Vec<u8>> {
hex::decode(input.trim()).context("invalid hex")
}
pub fn encode_hex(input: &[u8]) -> String {
hex::encode(input)
}
pub fn extract_key_32(input: &str) -> Result<[u8; 32]> {
let bytes = decode_hex(input)?;
let key: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("expected 32-byte key"))?;
Ok(key)
}
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(key).context("invalid AES-256 key")?;
let mut nonce_bytes = [0_u8; 12];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut out = nonce_bytes.to_vec();
out.extend(
cipher
.encrypt(nonce, plaintext)
.map_err(|_| anyhow::anyhow!("encryption failed"))?,
);
Ok(out)
}
pub fn decrypt(key: &[u8; 32], ciphertext: &[u8]) -> Result<Vec<u8>> {
if ciphertext.len() < 12 {
anyhow::bail!("ciphertext too short");
}
let cipher = Aes256Gcm::new_from_slice(key).context("invalid AES-256 key")?;
let (nonce, body) = ciphertext.split_at(12);
cipher
.decrypt(Nonce::from_slice(nonce), body)
.map_err(|_| anyhow::anyhow!("decryption failed"))
}

View File

@@ -0,0 +1,26 @@
[package]
name = "secrets-desktop-daemon"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_desktop_daemon"
path = "src/lib.rs"
[[bin]]
name = "secrets-desktop-daemon"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
axum.workspace = true
dotenvy.workspace = true
reqwest = { workspace = true, features = ["stream"] }
rmcp.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
secrets-device-auth = { path = "../device-auth" }

View File

@@ -0,0 +1,23 @@
use anyhow::Result;
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub bind: String,
}
pub fn load_config() -> Result<DaemonConfig> {
let bind =
std::env::var("SECRETS_DAEMON_BIND").unwrap_or_else(|_| "127.0.0.1:9515".to_string());
if bind.trim().is_empty() {
anyhow::bail!("SECRETS_DAEMON_BIND must not be empty");
}
Ok(DaemonConfig { bind })
}
pub fn load_persisted_device_token() -> Result<Option<String>> {
let token = std::env::var("SECRETS_DEVICE_LOGIN_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
Ok(token)
}

View File

@@ -0,0 +1,139 @@
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tokio::process::Command;
use crate::target::{ExecutionTarget, ResolvedTarget};
const MAX_OUTPUT_CHARS: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize)]
pub struct TargetExecInput {
pub target_ref: Option<String>,
pub command: String,
pub timeout_secs: Option<u64>,
pub working_dir: Option<String>,
pub env_overrides: Option<Map<String, Value>>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ExecResult {
pub resolved_target: ResolvedTarget,
pub resolved_env_keys: Vec<String>,
pub command: String,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
pub duration_ms: u128,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
fn truncate_output(text: String) -> (String, bool) {
if text.chars().count() <= MAX_OUTPUT_CHARS {
return (text, false);
}
let truncated = text.chars().take(MAX_OUTPUT_CHARS).collect::<String>();
(truncated, true)
}
fn stringify_env_override(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(v) => Some(v.to_string()),
Value::Number(v) => Some(v.to_string()),
other => serde_json::to_string(other).ok(),
}
}
fn apply_env_overrides(
env: &mut BTreeMap<String, String>,
overrides: Option<&Map<String, Value>>,
) -> Result<()> {
let Some(overrides) = overrides else {
return Ok(());
};
for (key, value) in overrides {
if key.is_empty() || key.contains('=') {
return Err(anyhow!("invalid env override key: {key}"));
}
if key.starts_with("TARGET_") {
return Err(anyhow!(
"env override `{key}` cannot override reserved TARGET_* variables"
));
}
if let Some(value) = stringify_env_override(value) {
env.insert(key.clone(), value);
}
}
Ok(())
}
pub async fn execute_command(
input: &TargetExecInput,
target: &ExecutionTarget,
timeout_secs: u64,
) -> Result<ExecResult> {
let mut env = target.env.clone();
apply_env_overrides(&mut env, input.env_overrides.as_ref())?;
let started = std::time::Instant::now();
let mut command = Command::new("/bin/sh");
command
.arg("-lc")
.arg(&input.command)
.kill_on_drop(true)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(dir) = input.working_dir.as_ref().filter(|dir| !dir.is_empty()) {
command.current_dir(dir);
}
for (key, value) in &env {
command.env(key, value);
}
let child = command
.spawn()
.with_context(|| format!("failed to spawn command: {}", input.command))?;
let timed = tokio::time::timeout(
Duration::from_secs(timeout_secs.clamp(1, 86400)),
child.wait_with_output(),
)
.await;
let (exit_code, stdout, stderr, timed_out) = match timed {
Ok(output) => {
let output = output.context("failed waiting for command output")?;
(
output.status.code(),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
false,
)
}
Err(_) => (None, String::new(), "command timed out".to_string(), true),
};
let (stdout, stdout_truncated) = truncate_output(stdout);
let (stderr, stderr_truncated) = truncate_output(stderr);
Ok(ExecResult {
resolved_target: target.resolved.clone(),
resolved_env_keys: target.resolved_env_keys(),
command: input.command.clone(),
exit_code,
stdout,
stderr,
timed_out,
duration_ms: started.elapsed().as_millis(),
stdout_truncated,
stderr_truncated,
})
}

View File

@@ -0,0 +1,642 @@
pub mod config;
pub mod exec;
pub mod target;
pub mod vault_client;
use std::collections::HashMap;
use anyhow::{Context, Result, anyhow};
use axum::{
Router,
body::Body,
extract::State,
http::{StatusCode, header},
response::Response,
routing::{any, get},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
exec::{TargetExecInput, execute_command},
target::{TargetSnapshot, build_execution_target},
vault_client::{
EntryDetail, EntrySummary, SecretHistoryItem, SecretValueField, authorized_get,
authorized_patch, authorized_post, entry_detail_payload, fetch_entry_detail,
fetch_revealed_entry_secrets,
},
};
#[derive(Clone)]
pub struct AppState {
session_base: String,
client: reqwest::Client,
}
#[derive(Deserialize)]
struct JsonRpcRequest {
#[serde(default)]
id: Value,
method: String,
#[serde(default)]
params: Value,
}
fn json_response(status: StatusCode, value: Value) -> Response {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(value.to_string()))
.expect("build response")
}
fn jsonrpc_result_response(id: Value, result: Value) -> Response {
json_response(
StatusCode::OK,
json!({
"jsonrpc": "2.0",
"id": id,
"result": result,
}),
)
}
fn tool_success_response(id: Value, value: Value) -> Response {
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
jsonrpc_result_response(
id,
json!({
"content": [
{
"type": "text",
"text": pretty
}
],
"isError": false
}),
)
}
fn tool_error_response(id: Value, message: impl Into<String>) -> Response {
jsonrpc_result_response(
id,
json!({
"content": [
{
"type": "text",
"text": message.into()
}
],
"isError": true
}),
)
}
fn initialize_response(id: Value) -> Response {
let session_id = format!(
"desktop-daemon-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
);
let payload = json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "secrets-desktop-daemon",
"version": env!("CARGO_PKG_VERSION"),
"title": "Secrets Desktop Daemon"
},
"instructions": "Preferred tools: secrets_entry_find, secrets_entry_get, secrets_entry_add, secrets_entry_update, secrets_entry_delete, secrets_entry_restore, secrets_secret_add, secrets_secret_update, secrets_secret_delete, secrets_secret_history, secrets_secret_rollback, and target_exec. All data is resolved from the desktop app's unlocked local vault session."
}
});
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.header("mcp-session-id", session_id)
.body(Body::from(payload.to_string()))
.expect("build response")
}
fn tool_definitions() -> Vec<Value> {
vec![
json!({
"name": "secrets_entry_find",
"description": "Find entries from the user's secrets vault.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": ["string", "null"] },
"folder": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] }
}
}
}),
json!({
"name": "secrets_entry_get",
"description": "Get one entry from the unlocked local vault by entry id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_entry_add",
"description": "Create a new entry and optionally include initial secrets.",
"inputSchema": {
"type": "object",
"properties": {
"folder": { "type": "string" },
"name": { "type": "string" },
"type": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] },
"secrets": {
"type": ["array", "null"],
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"secret_type": { "type": ["string", "null"] },
"value": { "type": "string" }
},
"required": ["name", "value"]
}
}
},
"required": ["folder", "name"]
}
}),
json!({
"name": "secrets_entry_update",
"description": "Update an existing entry by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"folder": { "type": ["string", "null"] },
"name": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_entry_delete",
"description": "Move an entry into recycle bin by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_entry_restore",
"description": "Restore a deleted entry from recycle bin by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_add",
"description": "Create one secret under an existing entry.",
"inputSchema": {
"type": "object",
"properties": {
"entry_id": { "type": "string" },
"name": { "type": "string" },
"secret_type": { "type": ["string", "null"] },
"value": { "type": "string" }
},
"required": ["entry_id", "name", "value"]
}
}),
json!({
"name": "secrets_secret_update",
"description": "Update one secret by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": ["string", "null"] },
"secret_type": { "type": ["string", "null"] },
"value": { "type": ["string", "null"] }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_delete",
"description": "Delete one secret by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_history",
"description": "List history snapshots for one secret by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_rollback",
"description": "Rollback one secret by id to a previous version or history id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"version": { "type": ["integer", "null"] },
"history_id": { "type": ["integer", "null"] }
},
"required": ["id"]
}
}),
json!({
"name": "target_exec",
"description": "Execute a local shell command with resolved TARGET_* environment variables from one entry.",
"inputSchema": {
"type": "object",
"properties": {
"target_ref": { "type": ["string", "null"] },
"command": { "type": "string" },
"timeout_secs": { "type": ["integer", "null"] },
"working_dir": { "type": ["string", "null"] },
"env_overrides": { "type": ["object", "null"] }
},
"required": ["target_ref", "command"]
}
}),
]
}
fn entry_detail_to_snapshot(detail: &EntryDetail) -> TargetSnapshot {
let metadata = detail
.metadata
.iter()
.map(|field| (field.label.clone(), Value::String(field.value.clone())))
.collect();
let secret_fields = detail
.secrets
.iter()
.map(|secret| crate::target::SecretFieldRef {
name: secret.name.clone(),
secret_type: Some(secret.secret_type.clone()),
})
.collect();
TargetSnapshot {
id: detail.id.clone(),
folder: detail.folder.clone(),
name: detail.name.clone(),
entry_type: Some(detail.cipher_type.clone()),
metadata,
secret_fields,
}
}
fn revealed_secrets_to_env(secrets: &[SecretValueField]) -> HashMap<String, Value> {
secrets
.iter()
.map(|secret| (secret.name.clone(), Value::String(secret.value.clone())))
.collect()
}
async fn call_tool(state: &AppState, name: &str, arguments: Value) -> Result<Value> {
match name {
"secrets_entry_find" => {
let folder = arguments
.get("folder")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let query = arguments
.get("query")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let entry_type = arguments
.get("type")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let mut params = Vec::new();
if let Some(folder) = folder {
params.push(("folder", folder));
}
if let Some(query) = query {
params.push(("query", query));
}
if let Some(entry_type) = entry_type {
params.push(("entry_type", entry_type));
}
params.push(("deleted_only", "false".to_string()));
let entries = authorized_get(state, "/vault/entries", &params)
.await?
.json::<Vec<EntrySummary>>()
.await
.context("failed to decode entries list")?;
Ok(json!({
"entries": entries.into_iter().map(|entry| {
json!({
"id": entry.id,
"folder": entry.folder,
"name": entry.name,
"type": entry.cipher_type
})
}).collect::<Vec<_>>()
}))
}
"secrets_entry_get" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let detail = fetch_entry_detail(state, id).await?;
let secrets = fetch_revealed_entry_secrets(state, id).await?;
Ok(entry_detail_payload(&detail, Some(&secrets)))
}
"secrets_entry_add" => {
let folder = arguments
.get("folder")
.and_then(Value::as_str)
.context("folder is required")?;
let name = arguments
.get("name")
.and_then(Value::as_str)
.context("name is required")?;
let entry_type = arguments
.get("type")
.and_then(Value::as_str)
.unwrap_or("entry");
let metadata = arguments
.get("metadata")
.cloned()
.unwrap_or_else(|| json!({}));
let res = authorized_post(
state,
"/vault/entries",
&json!({
"folder": folder,
"name": name,
"entry_type": entry_type,
"metadata": metadata,
"secrets": arguments.get("secrets").cloned().unwrap_or(Value::Null)
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode create result")?)
}
"secrets_entry_update" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let body = json!({
"folder": arguments.get("folder").cloned().unwrap_or(Value::Null),
"entry_type": arguments.get("type").cloned().unwrap_or(Value::Null),
"title": arguments.get("name").cloned().unwrap_or(Value::Null),
"metadata": arguments.get("metadata").cloned().unwrap_or(Value::Null)
});
let res = authorized_patch(state, &format!("/vault/entries/{id}"), &body).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode update result")?)
}
"secrets_entry_delete" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res =
authorized_post(state, &format!("/vault/entries/{id}/delete"), &json!({})).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode delete result")?)
}
"secrets_entry_restore" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res =
authorized_post(state, &format!("/vault/entries/{id}/restore"), &json!({})).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode restore result")?)
}
"secrets_secret_add" => {
let entry_id = arguments
.get("entry_id")
.and_then(Value::as_str)
.context("entry_id is required")?;
let name = arguments
.get("name")
.and_then(Value::as_str)
.context("name is required")?;
let value = arguments
.get("value")
.and_then(Value::as_str)
.context("value is required")?;
let res = authorized_post(
state,
&format!("/vault/entries/{entry_id}/secrets"),
&json!({
"name": name,
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
"value": value
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret create result")?)
}
"secrets_secret_update" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res = authorized_patch(
state,
&format!("/vault/secrets/{id}"),
&json!({
"name": arguments.get("name").cloned().unwrap_or(Value::Null),
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
"value": arguments.get("value").cloned().unwrap_or(Value::Null)
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret update result")?)
}
"secrets_secret_delete" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res =
authorized_post(state, &format!("/vault/secrets/{id}/delete"), &json!({})).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret delete result")?)
}
"secrets_secret_history" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let history = authorized_get(state, &format!("/vault/secrets/{id}/history"), &[])
.await?
.json::<Vec<SecretHistoryItem>>()
.await
.context("failed to decode secret history")?;
Ok(json!({
"history": history.into_iter().map(|item| {
json!({
"history_id": item.history_id,
"secret_id": item.secret_id,
"name": item.name,
"type": item.secret_type,
"masked_value": item.masked_value,
"value": item.value,
"version": item.version,
"action": item.action,
"created_at": item.created_at
})
}).collect::<Vec<_>>()
}))
}
"secrets_secret_rollback" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res = authorized_post(
state,
&format!("/vault/secrets/{id}/rollback"),
&json!({
"version": arguments.get("version").cloned().unwrap_or(Value::Null),
"history_id": arguments.get("history_id").cloned().unwrap_or(Value::Null)
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret rollback result")?)
}
"target_exec" => {
let input: TargetExecInput =
serde_json::from_value(arguments).context("invalid target_exec arguments")?;
let target_ref = input
.target_ref
.as_ref()
.context("target_ref is required")?;
let detail = fetch_entry_detail(state, target_ref).await?;
let secrets = fetch_revealed_entry_secrets(state, target_ref).await?;
let execution_target = build_execution_target(
&entry_detail_to_snapshot(&detail),
&revealed_secrets_to_env(&secrets),
)?;
let result =
execute_command(&input, &execution_target, input.timeout_secs.unwrap_or(30))
.await?;
Ok(serde_json::to_value(result).context("failed to encode exec result")?)
}
other => Err(anyhow!("unsupported tool: {other}")),
}
}
pub async fn handle_mcp(State(state): State<AppState>, body: String) -> Response {
let request: JsonRpcRequest = match serde_json::from_str(&body) {
Ok(request) => request,
Err(err) => {
return json_response(
StatusCode::BAD_REQUEST,
json!({
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32600,
"message": format!("invalid request: {err}")
}
}),
);
}
};
match request.method.as_str() {
"initialize" => initialize_response(request.id),
"tools/list" => jsonrpc_result_response(request.id, json!({ "tools": tool_definitions() })),
"tools/call" => {
let name = request
.params
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let arguments = request
.params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
match call_tool(&state, name, arguments).await {
Ok(value) => tool_success_response(request.id, value),
Err(err) => tool_error_response(request.id, err.to_string()),
}
}
other => json_response(
StatusCode::OK,
json!({
"jsonrpc": "2.0",
"id": request.id,
"error": {
"code": -32601,
"message": format!("method `{other}` not supported by secrets-desktop-daemon")
}
}),
),
}
}
pub async fn build_router() -> Result<Router> {
let session_base = std::env::var("SECRETS_DESKTOP_SESSION_URL")
.unwrap_or_else(|_| "http://127.0.0.1:9520".to_string());
let state = AppState {
session_base,
client: reqwest::Client::new(),
};
Ok(Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/mcp", any(handle_mcp))
.with_state(state))
}

View File

@@ -0,0 +1,26 @@
use anyhow::{Context, Result};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "secrets_desktop_daemon=info".into()),
)
.init();
let config = secrets_desktop_daemon::config::load_config()?;
let app = secrets_desktop_daemon::build_router().await?;
let listener = tokio::net::TcpListener::bind(&config.bind)
.await
.with_context(|| format!("failed to bind {}", config.bind))?;
tracing::info!(bind = %config.bind, "secrets-desktop-daemon listening");
axum::serve(listener, app)
.await
.context("daemon server error")?;
Ok(())
}

View File

@@ -0,0 +1,332 @@
use std::collections::{BTreeMap, HashMap};
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SecretFieldRef {
pub name: String,
#[serde(rename = "type")]
pub secret_type: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TargetSnapshot {
pub id: String,
pub folder: String,
pub name: String,
#[serde(rename = "type")]
pub entry_type: Option<String>,
#[serde(default)]
pub metadata: Map<String, Value>,
#[serde(default)]
pub secret_fields: Vec<SecretFieldRef>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ResolvedTarget {
pub id: String,
pub folder: String,
pub name: String,
#[serde(rename = "type")]
pub entry_type: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ExecutionTarget {
pub resolved: ResolvedTarget,
pub env: BTreeMap<String, String>,
}
impl ExecutionTarget {
pub fn resolved_env_keys(&self) -> Vec<String> {
self.env.keys().cloned().collect()
}
}
fn stringify_value(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(v) => Some(v.to_string()),
Value::Number(v) => Some(v.to_string()),
other => serde_json::to_string(other).ok(),
}
}
fn sanitize_env_key(key: &str) -> String {
let mut out = String::with_capacity(key.len());
for ch in key.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_uppercase());
} else {
out.push('_');
}
}
while out.contains("__") {
out = out.replace("__", "_");
}
out.trim_matches('_').to_string()
}
fn set_if_missing(env: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
if let Some(value) = value.filter(|v| !v.is_empty()) {
env.entry(key.to_string()).or_insert(value);
}
}
fn metadata_alias(metadata: &Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| metadata.get(*key))
.and_then(stringify_value)
}
fn secret_alias(
secrets: &HashMap<String, Value>,
secret_types: &HashMap<&str, Option<&str>>,
name_match: impl Fn(&str) -> bool,
type_match: impl Fn(Option<&str>) -> bool,
) -> Option<String> {
secrets.iter().find_map(|(name, value)| {
let normalized = sanitize_env_key(name);
let ty = secret_types.get(name.as_str()).copied().flatten();
if name_match(&normalized) || type_match(ty) {
stringify_value(value)
} else {
None
}
})
}
pub fn build_execution_target(
snapshot: &TargetSnapshot,
secrets: &HashMap<String, Value>,
) -> Result<ExecutionTarget> {
if snapshot.id.trim().is_empty() {
return Err(anyhow!("target snapshot missing id"));
}
let mut env = BTreeMap::new();
env.insert("TARGET_ENTRY_ID".to_string(), snapshot.id.clone());
env.insert("TARGET_NAME".to_string(), snapshot.name.clone());
env.insert("TARGET_FOLDER".to_string(), snapshot.folder.clone());
if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) {
env.insert("TARGET_TYPE".to_string(), entry_type.clone());
}
for (key, value) in &snapshot.metadata {
if let Some(value) = stringify_value(value) {
let name = sanitize_env_key(key);
if !name.is_empty() {
env.insert(format!("TARGET_META_{name}"), value);
}
}
}
let secret_type_map: HashMap<&str, Option<&str>> = snapshot
.secret_fields
.iter()
.map(|field| (field.name.as_str(), field.secret_type.as_deref()))
.collect();
for (key, value) in secrets {
if let Some(value) = stringify_value(value) {
let name = sanitize_env_key(key);
if !name.is_empty() {
env.insert(format!("TARGET_SECRET_{name}"), value);
}
}
}
set_if_missing(
&mut env,
"TARGET_HOST",
metadata_alias(
&snapshot.metadata,
&["public_ip", "ipv4", "private_ip", "host", "hostname"],
),
);
set_if_missing(
&mut env,
"TARGET_PORT",
metadata_alias(&snapshot.metadata, &["ssh_port", "port"]),
);
set_if_missing(
&mut env,
"TARGET_USER",
metadata_alias(&snapshot.metadata, &["username", "ssh_user", "user"]),
);
set_if_missing(
&mut env,
"TARGET_BASE_URL",
metadata_alias(&snapshot.metadata, &["base_url", "url", "endpoint"]),
);
set_if_missing(
&mut env,
"TARGET_API_KEY",
secret_alias(
secrets,
&secret_type_map,
|name| matches!(name, "API_KEY" | "APIKEY" | "ACCESS_KEY" | "ACCESS_KEY_ID"),
|_| false,
),
);
set_if_missing(
&mut env,
"TARGET_TOKEN",
secret_alias(
secrets,
&secret_type_map,
|name| name.contains("TOKEN"),
|_| false,
),
);
set_if_missing(
&mut env,
"TARGET_SSH_KEY",
secret_alias(
secrets,
&secret_type_map,
|name| name.contains("SSH") || name.ends_with("PEM"),
|ty| ty.is_some_and(|v| v.eq_ignore_ascii_case("ssh-key")),
),
);
Ok(ExecutionTarget {
resolved: ResolvedTarget {
id: snapshot.id.clone(),
folder: snapshot.folder.clone(),
name: snapshot.name.clone(),
entry_type: snapshot.entry_type.clone(),
},
env,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn build_snapshot() -> TargetSnapshot {
let mut metadata = Map::new();
metadata.insert(
"host".to_string(),
Value::String("git.example.com".to_string()),
);
metadata.insert("port".to_string(), Value::String("22".to_string()));
metadata.insert("username".to_string(), Value::String("deploy".to_string()));
metadata.insert(
"base_url".to_string(),
Value::String("https://api.example.com".to_string()),
);
TargetSnapshot {
id: "entry-1".to_string(),
folder: "infra".to_string(),
name: "production".to_string(),
entry_type: Some("ssh_key".to_string()),
metadata,
secret_fields: vec![
SecretFieldRef {
name: "api_key".to_string(),
secret_type: Some("text".to_string()),
},
SecretFieldRef {
name: "token".to_string(),
secret_type: Some("text".to_string()),
},
SecretFieldRef {
name: "ssh_key".to_string(),
secret_type: Some("ssh-key".to_string()),
},
],
}
}
#[test]
fn derives_standard_target_env_keys() {
let snapshot = build_snapshot();
let secrets = HashMap::from([
("api_key".to_string(), Value::String("ak-123".to_string())),
("token".to_string(), Value::String("tok-456".to_string())),
(
"ssh_key".to_string(),
Value::String("-----BEGIN KEY-----".to_string()),
),
]);
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
assert_eq!(
target.env.get("TARGET_ENTRY_ID").map(String::as_str),
Some("entry-1")
);
assert_eq!(
target.env.get("TARGET_NAME").map(String::as_str),
Some("production")
);
assert_eq!(
target.env.get("TARGET_FOLDER").map(String::as_str),
Some("infra")
);
assert_eq!(
target.env.get("TARGET_TYPE").map(String::as_str),
Some("ssh_key")
);
assert_eq!(
target.env.get("TARGET_HOST").map(String::as_str),
Some("git.example.com")
);
assert_eq!(
target.env.get("TARGET_PORT").map(String::as_str),
Some("22")
);
assert_eq!(
target.env.get("TARGET_USER").map(String::as_str),
Some("deploy")
);
assert_eq!(
target.env.get("TARGET_BASE_URL").map(String::as_str),
Some("https://api.example.com")
);
assert_eq!(
target.env.get("TARGET_API_KEY").map(String::as_str),
Some("ak-123")
);
assert_eq!(
target.env.get("TARGET_TOKEN").map(String::as_str),
Some("tok-456")
);
assert_eq!(
target.env.get("TARGET_SSH_KEY").map(String::as_str),
Some("-----BEGIN KEY-----")
);
}
#[test]
fn exports_sanitized_meta_and_secret_keys() {
let mut snapshot = build_snapshot();
snapshot.metadata.insert(
"private-ip".to_string(),
Value::String("10.0.0.8".to_string()),
);
let secrets = HashMap::from([(
"access key id".to_string(),
Value::String("access-1".to_string()),
)]);
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
assert_eq!(
target.env.get("TARGET_META_PRIVATE_IP").map(String::as_str),
Some("10.0.0.8")
);
assert_eq!(
target
.env
.get("TARGET_SECRET_ACCESS_KEY_ID")
.map(String::as_str),
Some("access-1")
);
}
}

View File

@@ -0,0 +1,168 @@
use std::collections::HashMap;
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct EntrySummary {
pub id: String,
pub folder: String,
#[serde(rename = "title")]
pub name: String,
#[serde(rename = "subtitle")]
pub cipher_type: String,
}
#[derive(Debug, Deserialize)]
pub struct EntryDetail {
pub id: String,
#[serde(rename = "title")]
pub name: String,
pub folder: String,
#[serde(rename = "entry_type")]
pub cipher_type: String,
pub metadata: Vec<DetailField>,
pub secrets: Vec<SecretField>,
}
#[derive(Debug, Deserialize)]
pub struct DetailField {
pub label: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct SecretField {
pub id: String,
pub name: String,
pub secret_type: String,
pub masked_value: String,
pub version: i64,
}
#[derive(Debug, Deserialize)]
pub struct SecretValueField {
pub id: String,
pub name: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct SecretHistoryItem {
pub history_id: i64,
pub secret_id: String,
pub name: String,
pub secret_type: String,
pub masked_value: String,
pub value: String,
pub version: i64,
pub action: String,
pub created_at: String,
}
pub async fn authorized_get(
state: &AppState,
path: &str,
query: &[(&str, String)],
) -> Result<reqwest::Response> {
state
.client
.get(format!("{}{}", state.session_base, path))
.query(query)
.send()
.await
.with_context(|| format!("desktop local vault unavailable: {path}"))?
.error_for_status()
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
}
pub async fn authorized_patch(
state: &AppState,
path: &str,
body: &Value,
) -> Result<reqwest::Response> {
state
.client
.patch(format!("{}{}", state.session_base, path))
.json(body)
.send()
.await
.with_context(|| format!("desktop local vault unavailable: {path}"))?
.error_for_status()
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
}
pub async fn authorized_post(
state: &AppState,
path: &str,
body: &Value,
) -> Result<reqwest::Response> {
state
.client
.post(format!("{}{}", state.session_base, path))
.json(body)
.send()
.await
.with_context(|| format!("desktop local vault unavailable: {path}"))?
.error_for_status()
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
}
pub async fn fetch_entry_detail(state: &AppState, entry_id: &str) -> Result<EntryDetail> {
authorized_get(state, &format!("/vault/entries/{entry_id}"), &[])
.await?
.json::<EntryDetail>()
.await
.context("failed to decode entry detail")
}
pub async fn fetch_revealed_entry_secrets(
state: &AppState,
entry_id: &str,
) -> Result<Vec<SecretValueField>> {
let detail = fetch_entry_detail(state, entry_id).await?;
let mut secrets = Vec::new();
for secret in detail.secrets {
let item = authorized_get(state, &format!("/vault/secrets/{}/value", secret.id), &[])
.await?
.json::<SecretValueField>()
.await
.context("failed to decode revealed secret value")?;
secrets.push(item);
}
Ok(secrets)
}
pub fn entry_detail_payload(detail: &EntryDetail, revealed: Option<&[SecretValueField]>) -> Value {
let revealed_by_id: HashMap<&str, &SecretValueField> = revealed
.unwrap_or(&[])
.iter()
.map(|secret| (secret.id.as_str(), secret))
.collect();
json!({
"id": detail.id,
"folder": detail.folder,
"name": detail.name,
"type": detail.cipher_type,
"metadata": detail.metadata.iter().map(|field| {
json!({
"label": field.label,
"value": field.value
})
}).collect::<Vec<_>>(),
"secrets": detail.secrets.iter().map(|secret| {
let revealed = revealed_by_id.get(secret.id.as_str());
json!({
"id": secret.id,
"name": secret.name,
"type": secret.secret_type,
"masked_value": secret.masked_value,
"value": revealed.map(|item| item.value.clone()),
"version": secret.version
})
}).collect::<Vec<_>>()
})
}

View File

@@ -0,0 +1,16 @@
[package]
name = "secrets-device-auth"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_device_auth"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
hex.workspace = true
rand.workspace = true
sha2.workspace = true
url.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,27 @@
use anyhow::{Context, Result};
use rand::{Rng, RngExt};
use sha2::{Digest, Sha256};
use url::Url;
pub fn loopback_redirect_uri(port: u16) -> Result<Url> {
Url::parse(&format!("http://127.0.0.1:{port}/oauth/callback"))
.context("failed to build loopback redirect URI")
}
pub fn new_device_fingerprint() -> String {
let mut bytes = [0_u8; 16];
rand::rng().fill(&mut bytes);
hex::encode(bytes)
}
pub fn new_device_login_token() -> String {
let mut bytes = [0_u8; 32];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub fn hash_device_login_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}

16
crates/domain/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "secrets-domain"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_domain"
path = "src/lib.rs"
[dependencies]
argon2 = "0.5.3"
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
uuid.workspace = true

68
crates/domain/src/auth.rs Normal file
View File

@@ -0,0 +1,68 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub email: Option<String>,
pub name: String,
pub avatar_url: Option<String>,
pub key_salt: Option<Vec<u8>>,
pub key_check: Option<Vec<u8>>,
pub key_params: Option<Value>,
pub key_version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: String,
pub platform: String,
pub client_version: String,
pub device_fingerprint: String,
pub created_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceLoginToken {
pub id: Uuid,
pub device_id: Uuid,
pub token_hash: String,
pub created_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LoginMethod {
GoogleOauth,
DeviceToken,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LoginResult {
Success,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientLoginEvent {
pub id: i64,
pub user_id: Uuid,
pub device_id: Uuid,
pub device_name: String,
pub platform: String,
pub client_version: String,
pub ip_addr: Option<String>,
pub forwarded_ip: Option<String>,
pub login_method: LoginMethod,
pub login_result: LoginResult,
pub created_at: DateTime<Utc>,
}

138
crates/domain/src/cipher.rs Normal file
View File

@@ -0,0 +1,138 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CipherType {
Login,
ApiKey,
SecureNote,
SshKey,
Identity,
Card,
}
impl CipherType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Login => "login",
Self::ApiKey => "api_key",
Self::SecureNote => "secure_note",
Self::SshKey => "ssh_key",
Self::Identity => "identity",
Self::Card => "card",
}
}
pub fn parse(input: &str) -> Self {
match input {
"login" => Self::Login,
"api_key" => Self::ApiKey,
"secure_note" => Self::SecureNote,
"ssh_key" => Self::SshKey,
"identity" => Self::Identity,
"card" => Self::Card,
_ => Self::SecureNote,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CustomField {
pub name: String,
pub value: Value,
#[serde(default)]
pub sensitive: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct LoginPayload {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub uris: Vec<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub totp: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ApiKeyPayload {
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub secret: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub host: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SecureNotePayload {
#[serde(default)]
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SshKeyPayload {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub private_key: Option<String>,
#[serde(default)]
pub passphrase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ItemPayload {
Login(LoginPayload),
ApiKey(ApiKeyPayload),
SecureNote(SecureNotePayload),
SshKey(SshKeyPayload),
}
impl Default for ItemPayload {
fn default() -> Self {
Self::SecureNote(SecureNotePayload::default())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CipherView {
pub id: Uuid,
pub cipher_type: CipherType,
pub name: String,
pub folder: String,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub custom_fields: Vec<CustomField>,
#[serde(default)]
pub deleted_at: Option<DateTime<Utc>>,
pub revision_date: DateTime<Utc>,
pub payload: ItemPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Cipher {
pub id: Uuid,
pub user_id: Uuid,
pub object_kind: String,
pub cipher_type: CipherType,
pub revision: i64,
pub cipher_version: i32,
pub ciphertext: Vec<u8>,
pub content_hash: String,
pub deleted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,15 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DomainError {
#[error("resource not found")]
NotFound,
#[error("resource already exists")]
Conflict,
#[error("validation failed: {0}")]
Validation(String),
#[error("authentication failed")]
AuthenticationFailed,
#[error("decryption failed")]
DecryptionFailed,
}

37
crates/domain/src/kdf.rs Normal file
View File

@@ -0,0 +1,37 @@
use argon2::{Algorithm, Argon2, Params, Version};
use serde::{Deserialize, Serialize};
use crate::DomainError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KdfType {
Argon2id,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct KdfConfig {
pub kdf_type: KdfType,
pub memory_kib: u32,
pub iterations: u32,
pub parallelism: u32,
}
impl Default for KdfConfig {
fn default() -> Self {
Self {
kdf_type: KdfType::Argon2id,
memory_kib: 64 * 1024,
iterations: 3,
parallelism: 4,
}
}
}
impl KdfConfig {
pub fn build_argon2(&self) -> Result<Argon2<'static>, DomainError> {
let params = Params::new(self.memory_kib, self.iterations, self.parallelism, Some(32))
.map_err(|err| DomainError::Validation(err.to_string()))?;
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
}

19
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,19 @@
pub mod auth;
pub mod cipher;
pub mod error;
pub mod kdf;
pub mod sync;
pub mod vault_object;
pub use auth::{ClientLoginEvent, Device, DeviceLoginToken, LoginMethod, LoginResult, User};
pub use cipher::{
ApiKeyPayload, Cipher, CipherType, CipherView, CustomField, ItemPayload, LoginPayload,
SecureNotePayload, SshKeyPayload,
};
pub use error::DomainError;
pub use kdf::{KdfConfig, KdfType};
pub use sync::{
SyncAcceptedChange, SyncConflict, SyncPullRequest, SyncPullResponse, SyncPushRequest,
SyncPushResponse,
};
pub use vault_object::{VaultObjectChange, VaultObjectEnvelope, VaultObjectKind, VaultTombstone};

47
crates/domain/src/sync.rs Normal file
View File

@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use crate::vault_object::{VaultObjectChange, VaultObjectEnvelope, VaultTombstone};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncPullRequest {
pub cursor: Option<i64>,
pub limit: Option<i64>,
#[serde(default)]
pub include_deleted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncPullResponse {
pub server_revision: i64,
pub next_cursor: i64,
pub has_more: bool,
pub objects: Vec<VaultObjectEnvelope>,
pub tombstones: Vec<VaultTombstone>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncPushRequest {
pub changes: Vec<VaultObjectChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncAcceptedChange {
pub change_id: uuid::Uuid,
pub object_id: uuid::Uuid,
pub revision: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncConflict {
pub change_id: uuid::Uuid,
pub object_id: uuid::Uuid,
pub reason: String,
pub server_object: Option<VaultObjectEnvelope>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncPushResponse {
pub server_revision: i64,
pub accepted: Vec<SyncAcceptedChange>,
pub conflicts: Vec<SyncConflict>,
}

View File

@@ -0,0 +1,48 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VaultObjectKind {
Cipher,
}
impl VaultObjectKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Cipher => "cipher",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultObjectEnvelope {
pub object_id: Uuid,
pub object_kind: VaultObjectKind,
pub revision: i64,
pub cipher_version: i32,
pub ciphertext: Vec<u8>,
pub content_hash: String,
pub deleted_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultObjectChange {
pub change_id: Uuid,
pub object_id: Uuid,
pub object_kind: VaultObjectKind,
pub operation: String,
pub base_revision: Option<i64>,
pub cipher_version: Option<i32>,
pub ciphertext: Option<Vec<u8>>,
pub content_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultTombstone {
pub object_id: Uuid,
pub revision: i64,
pub deleted_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,15 @@
[package]
name = "secrets-infrastructure-db"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_infrastructure_db"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
dotenvy.workspace = true
sqlx.workspace = true
tracing.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,29 @@
mod migrate;
use anyhow::{Context, Result};
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use std::str::FromStr;
pub use migrate::migrate_current_schema;
pub fn load_database_url() -> Result<String> {
std::env::var("SECRETS_DATABASE_URL")
.context("SECRETS_DATABASE_URL is required for current services")
}
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
let options =
PgConnectOptions::from_str(database_url).context("failed to parse SECRETS_DATABASE_URL")?;
let pool = PgPoolOptions::new()
.max_connections(
std::env::var("SECRETS_DATABASE_POOL_SIZE")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(10),
)
.connect_with(options)
.await
.context("failed to connect to PostgreSQL")?;
Ok(pool)
}

View File

@@ -0,0 +1,130 @@
use anyhow::Result;
use sqlx::PgPool;
pub async fn migrate_current_schema(pool: &PgPool) -> Result<()> {
sqlx::raw_sql(
r#"
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(),
email VARCHAR(256),
name VARCHAR(256) NOT NULL DEFAULT '',
avatar_url TEXT,
key_salt BYTEA,
key_check BYTEA,
key_params JSONB,
key_version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oauth_accounts (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL,
provider_id VARCHAR(256) NOT NULL,
email VARCHAR(256),
name VARCHAR(256),
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_id),
UNIQUE(user_id, provider)
);
CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
display_name VARCHAR(256) NOT NULL,
platform VARCHAR(64) NOT NULL,
client_version VARCHAR(64) NOT NULL,
device_fingerprint TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id);
CREATE TABLE IF NOT EXISTS device_login_tokens (
id UUID PRIMARY KEY DEFAULT uuidv7(),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_device_login_tokens_device_id ON device_login_tokens(device_id);
CREATE TABLE IF NOT EXISTS auth_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
device_name VARCHAR(256) NOT NULL,
platform VARCHAR(64) NOT NULL,
client_version VARCHAR(64) NOT NULL,
ip_addr TEXT,
forwarded_ip TEXT,
login_method VARCHAR(32) NOT NULL,
login_result VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_auth_events_user_id_created_at
ON auth_events(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_auth_events_device_id_created_at
ON auth_events(device_id, created_at DESC);
CREATE TABLE IF NOT EXISTS desktop_login_sessions (
session_id TEXT PRIMARY KEY,
oauth_state TEXT NOT NULL UNIQUE,
pkce_verifier TEXT NOT NULL,
device_name VARCHAR(256) NOT NULL,
platform VARCHAR(64) NOT NULL,
client_version VARCHAR(64) NOT NULL,
device_fingerprint TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
error_message TEXT,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
device_token TEXT,
device_token_hash TEXT,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_desktop_login_sessions_status_expires
ON desktop_login_sessions(status, expires_at);
CREATE TABLE IF NOT EXISTS vault_objects (
object_id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
object_kind VARCHAR(32) NOT NULL,
revision BIGINT NOT NULL,
cipher_version INTEGER NOT NULL DEFAULT 1,
ciphertext BYTEA NOT NULL DEFAULT '\x',
content_hash TEXT NOT NULL DEFAULT '',
deleted_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by_device UUID REFERENCES devices(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_vault_objects_user_revision
ON vault_objects(user_id, revision ASC);
CREATE INDEX IF NOT EXISTS idx_vault_objects_user_deleted
ON vault_objects(user_id, deleted_at);
CREATE TABLE IF NOT EXISTS vault_object_revisions (
object_id UUID NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
revision BIGINT NOT NULL,
cipher_version INTEGER NOT NULL DEFAULT 1,
ciphertext BYTEA NOT NULL DEFAULT '\x',
content_hash TEXT NOT NULL DEFAULT '',
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (object_id, revision)
);
CREATE INDEX IF NOT EXISTS idx_vault_object_revisions_user_revision
ON vault_object_revisions(user_id, revision ASC);
"#,
)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -1,26 +0,0 @@
[package]
name = "secrets-core"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_core"
path = "src/lib.rs"
[dependencies]
aes-gcm.workspace = true
anyhow.workspace = true
chrono.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
sqlx.workspace = true
toml.workspace = true
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile = "3"

View File

@@ -1,88 +0,0 @@
use serde_json::{Value, json};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub const ACTION_LOGIN: &str = "login";
pub const NAMESPACE_AUTH: &str = "auth";
fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str>) -> Value {
json!({
"provider": provider,
"client_ip": client_ip,
"user_agent": user_agent,
})
}
/// Write a login audit entry without requiring an explicit transaction.
pub async fn log_login(
pool: &PgPool,
kind: &str,
provider: &str,
user_id: Uuid,
client_ip: Option<&str>,
user_agent: Option<&str>,
) {
let detail = login_detail(provider, client_ip, user_agent);
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(ACTION_LOGIN)
.bind(NAMESPACE_AUTH)
.bind(kind)
.bind(provider)
.bind(&detail)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, kind, provider, "failed to write login audit log");
} else {
tracing::debug!(kind, provider, ?user_id, "login audit logged");
}
}
/// Write an audit entry within an existing transaction.
pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>,
user_id: Option<Uuid>,
action: &str,
namespace: &str,
kind: &str,
name: &str,
detail: Value,
) {
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(action)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&detail)
.execute(&mut **tx)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log");
} else {
tracing::debug!(action, namespace, kind, name, "audit logged");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_detail_includes_expected_fields() {
let detail = login_detail("google", Some("127.0.0.1"), Some("Mozilla/5.0"));
assert_eq!(detail["provider"], "google");
assert_eq!(detail["client_ip"], "127.0.0.1");
assert_eq!(detail["user_agent"], "Mozilla/5.0");
}
}

View File

@@ -1,20 +0,0 @@
use anyhow::Result;
/// Resolve database URL from environment.
/// Priority: `SECRETS_DATABASE_URL` env var → error.
pub fn resolve_db_url(override_url: &str) -> Result<String> {
if !override_url.is_empty() {
return Ok(override_url.to_string());
}
if let Ok(url) = std::env::var("SECRETS_DATABASE_URL")
&& !url.is_empty()
{
return Ok(url);
}
anyhow::bail!(
"Database not configured. Set the SECRETS_DATABASE_URL environment variable.\n\
Example: SECRETS_DATABASE_URL=postgres://user:pass@host:port/dbname"
)
}

View File

@@ -1,207 +0,0 @@
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, AeadCore, KeyInit, OsRng},
};
use anyhow::{Context, Result, bail};
use serde_json::Value;
const NONCE_LEN: usize = 12;
// ─── AES-256-GCM encrypt / decrypt ───────────────────────────────────────────
/// Encrypt plaintext bytes with AES-256-GCM.
/// Returns `nonce (12 B) || ciphertext+tag`.
pub fn encrypt(master_key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let key = Key::<Aes256Gcm>::from_slice(master_key);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| anyhow::anyhow!("AES-256-GCM encryption failed: {}", e))?;
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Decrypt `nonce (12 B) || ciphertext+tag` with AES-256-GCM.
pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < NONCE_LEN {
bail!(
"encrypted data too short ({}B); possibly corrupted",
data.len()
);
}
let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN);
let key = Key::<Aes256Gcm>::from_slice(master_key);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data"))
}
// ─── JSON helpers ─────────────────────────────────────────────────────────────
/// Serialize a JSON Value and encrypt it. Returns the encrypted blob.
pub fn encrypt_json(master_key: &[u8; 32], value: &Value) -> Result<Vec<u8>> {
let bytes = serde_json::to_vec(value).context("serialize JSON for encryption")?;
encrypt(master_key, &bytes)
}
/// Decrypt an encrypted blob and deserialize it as a JSON Value.
pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
let bytes = decrypt(master_key, data)?;
serde_json::from_slice(&bytes).context("deserialize decrypted JSON")
}
// ─── Per-user key management (DEPRECATED — kept only for migration) ───────────
/// Generate a new random 32-byte per-user encryption key.
#[allow(dead_code)]
pub fn generate_user_key() -> [u8; 32] {
use aes_gcm::aead::rand_core::RngCore;
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
key
}
/// Wrap a per-user key with the server master key using AES-256-GCM.
#[allow(dead_code)]
pub fn wrap_user_key(server_master_key: &[u8; 32], user_key: &[u8; 32]) -> Result<Vec<u8>> {
encrypt(server_master_key, user_key.as_ref())
}
/// Unwrap a per-user key using the server master key.
#[allow(dead_code)]
pub fn unwrap_user_key(server_master_key: &[u8; 32], wrapped: &[u8]) -> Result<[u8; 32]> {
let bytes = decrypt(server_master_key, wrapped)?;
if bytes.len() != 32 {
bail!("unwrapped user key has unexpected length {}", bytes.len());
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
// ─── Client-supplied key extraction ──────────────────────────────────────────
/// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key.
pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
let bytes = hex::decode_hex(hex_str.trim())?;
if bytes.len() != 32 {
bail!(
"X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes",
bytes.len()
);
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
// ─── Server master key ────────────────────────────────────────────────────────
/// Load the server master key from `SERVER_MASTER_KEY` environment variable (64 hex chars).
pub fn load_master_key_auto() -> Result<[u8; 32]> {
let hex_str = std::env::var("SERVER_MASTER_KEY").map_err(|_| {
anyhow::anyhow!(
"SERVER_MASTER_KEY is not set. \
Generate one with: openssl rand -hex 32"
)
})?;
if hex_str.is_empty() {
bail!("SERVER_MASTER_KEY is set but empty");
}
let bytes = hex::decode_hex(hex_str.trim())?;
if bytes.len() != 32 {
bail!(
"SERVER_MASTER_KEY must be 64 hex chars (32 bytes), got {} bytes",
bytes.len()
);
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
// ─── Public hex helpers ───────────────────────────────────────────────────────
pub mod hex {
use anyhow::{Result, bail};
pub fn encode_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
let s = s.trim();
if !s.len().is_multiple_of(2) {
bail!("hex string has odd length");
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_encrypt_decrypt() {
let key = [0x42u8; 32];
let plaintext = b"hello world";
let enc = encrypt(&key, plaintext).unwrap();
let dec = decrypt(&key, &enc).unwrap();
assert_eq!(dec, plaintext);
}
#[test]
fn encrypt_produces_different_ciphertexts() {
let key = [0x42u8; 32];
let plaintext = b"hello world";
let enc1 = encrypt(&key, plaintext).unwrap();
let enc2 = encrypt(&key, plaintext).unwrap();
assert_ne!(enc1, enc2);
}
#[test]
fn wrong_key_fails_decryption() {
let key1 = [0x42u8; 32];
let key2 = [0x43u8; 32];
let enc = encrypt(&key1, b"secret").unwrap();
assert!(decrypt(&key2, &enc).is_err());
}
#[test]
fn json_roundtrip() {
let key = [0x42u8; 32];
let value = serde_json::json!({"token": "abc123", "password": "hunter2"});
let enc = encrypt_json(&key, &value).unwrap();
let dec = decrypt_json(&key, &enc).unwrap();
assert_eq!(dec, value);
}
#[test]
fn user_key_wrap_unwrap_roundtrip() {
let server_key = [0xABu8; 32];
let user_key = [0xCDu8; 32];
let wrapped = wrap_user_key(&server_key, &user_key).unwrap();
let unwrapped = unwrap_user_key(&server_key, &wrapped).unwrap();
assert_eq!(unwrapped, user_key);
}
#[test]
fn user_key_wrap_wrong_server_key_fails() {
let server_key1 = [0xABu8; 32];
let server_key2 = [0xEFu8; 32];
let user_key = [0xCDu8; 32];
let wrapped = wrap_user_key(&server_key1, &user_key).unwrap();
assert!(unwrap_user_key(&server_key2, &wrapped).is_err());
}
}

View File

@@ -1,300 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
tracing::debug!("connecting to database");
let pool = PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(database_url)
.await?;
tracing::debug!("database connection established");
Ok(pool)
}
pub async fn migrate(pool: &PgPool) -> Result<()> {
tracing::debug!("running migrations");
sqlx::raw_sql(
r#"
-- ── entries: top-level entities ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS entries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Legacy unique constraint without user_id (single-user mode)
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
ON entries(namespace, kind, name)
WHERE user_id IS NULL;
-- Multi-user unique constraint
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
ON entries(user_id, namespace, kind, name)
WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_entries_namespace ON entries(namespace);
CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind);
CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_entries_tags ON entries USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops);
-- ── secrets: one row per encrypted field ─────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL,
encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(entry_id, field_name)
);
CREATE INDEX IF NOT EXISTS idx_secrets_entry_id ON secrets(entry_id);
-- ── audit_log: append-only operation log ─────────────────────────────────
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id UUID,
action VARCHAR(32) NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL;
-- ── entries_history ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS entries_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
version BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_entries_history_entry_id
ON entries_history(entry_id, version DESC);
CREATE INDEX IF NOT EXISTS idx_entries_history_ns_kind_name
ON entries_history(namespace, kind, name, version DESC);
-- Backfill: add user_id to entries_history for multi-tenant isolation
ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID;
CREATE INDEX IF NOT EXISTS idx_entries_history_user_id
ON entries_history(user_id) WHERE user_id IS NOT NULL;
ALTER TABLE entries_history DROP COLUMN IF EXISTS actor;
-- ── secrets_history: field-level snapshot ────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
secret_id UUID NOT NULL,
entry_version BIGINT NOT NULL,
field_name VARCHAR(256) NOT NULL,
encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_secrets_history_entry_id
ON secrets_history(entry_id, entry_version DESC);
CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id
ON secrets_history(secret_id);
-- Drop redundant actor column (derivable via entries_history JOIN)
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
-- Drop redundant actor column; user_id already identifies the business user
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
-- ── users ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(),
email VARCHAR(256),
name VARCHAR(256) NOT NULL DEFAULT '',
avatar_url TEXT,
key_salt BYTEA,
key_check BYTEA,
key_params JSONB,
api_key TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── oauth_accounts: per-provider identity links ───────────────────────────
CREATE TABLE IF NOT EXISTS oauth_accounts (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL,
provider_id VARCHAR(256) NOT NULL,
email VARCHAR(256),
name VARCHAR(256),
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_id)
);
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider
ON oauth_accounts(user_id, provider);
"#,
)
.execute(pool)
.await?;
restore_plaintext_api_keys(pool).await?;
tracing::debug!("migrations complete");
Ok(())
}
async fn restore_plaintext_api_keys(pool: &PgPool) -> Result<()> {
let has_users_api_key: bool = sqlx::query_scalar(
"SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'api_key'
)",
)
.fetch_one(pool)
.await?;
if !has_users_api_key {
sqlx::query("ALTER TABLE users ADD COLUMN api_key TEXT")
.execute(pool)
.await?;
sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key) WHERE api_key IS NOT NULL")
.execute(pool)
.await?;
}
let has_api_keys_table: bool = sqlx::query_scalar(
"SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'api_keys'
)",
)
.fetch_one(pool)
.await?;
if !has_api_keys_table {
return Ok(());
}
#[derive(sqlx::FromRow)]
struct UserWithoutKey {
id: uuid::Uuid,
}
let users_without_key: Vec<UserWithoutKey> =
sqlx::query_as("SELECT DISTINCT user_id AS id FROM api_keys WHERE user_id NOT IN (SELECT id FROM users WHERE api_key IS NOT NULL)")
.fetch_all(pool)
.await?;
for user in users_without_key {
let new_key = crate::service::api_key::generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user.id)
.execute(pool)
.await?;
}
sqlx::query("DROP TABLE IF EXISTS api_keys")
.execute(pool)
.await?;
Ok(())
}
// ── Entry-level history snapshot ─────────────────────────────────────────────
pub struct EntrySnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub user_id: Option<uuid::Uuid>,
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub version: i64,
pub action: &'a str,
pub tags: &'a [String],
pub metadata: &'a Value,
}
pub async fn snapshot_entry_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: EntrySnapshotParams<'_>,
) -> Result<()> {
sqlx::query(
"INSERT INTO entries_history \
(entry_id, namespace, kind, name, version, action, tags, metadata, user_id) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.bind(p.entry_id)
.bind(p.namespace)
.bind(p.kind)
.bind(p.name)
.bind(p.version)
.bind(p.action)
.bind(p.tags)
.bind(p.metadata)
.bind(p.user_id)
.execute(&mut **tx)
.await?;
Ok(())
}
// ── Secret field-level history snapshot ──────────────────────────────────────
pub struct SecretSnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub secret_id: uuid::Uuid,
pub entry_version: i64,
pub field_name: &'a str,
pub encrypted: &'a [u8],
pub action: &'a str,
}
pub async fn snapshot_secret_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: SecretSnapshotParams<'_>,
) -> Result<()> {
sqlx::query(
"INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, encrypted, action) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(p.entry_id)
.bind(p.secret_id)
.bind(p.entry_version)
.bind(p.field_name)
.bind(p.encrypted)
.bind(p.action)
.execute(&mut **tx)
.await?;
Ok(())
}
// ── DB helpers ────────────────────────────────────────────────────────────────

View File

@@ -1,6 +0,0 @@
pub mod audit;
pub mod config;
pub mod crypto;
pub mod db;
pub mod models;
pub mod service;

View File

@@ -1,263 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use uuid::Uuid;
/// A top-level entry (server, service, key, …).
/// Sensitive fields are stored separately in `secrets`.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Entry {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub namespace: String,
pub kind: String,
pub name: String,
pub tags: Vec<String>,
pub metadata: Value,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A single encrypted field belonging to an Entry.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecretField {
pub id: Uuid,
pub entry_id: Uuid,
pub field_name: String,
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
pub encrypted: Vec<u8>,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// ── Internal query row types (shared across commands) ─────────────────────────
/// Minimal entry row fetched for write operations (add / update / delete / rollback).
#[derive(Debug, sqlx::FromRow)]
pub struct EntryRow {
pub id: Uuid,
pub version: i64,
pub tags: Vec<String>,
pub metadata: Value,
}
/// Minimal secret field row fetched before snapshots or cascade deletes.
#[derive(Debug, sqlx::FromRow)]
pub struct SecretFieldRow {
pub id: Uuid,
pub field_name: String,
pub encrypted: Vec<u8>,
}
// ── Export / Import types ──────────────────────────────────────────────────────
/// Supported file formats for export/import.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExportFormat {
Json,
Toml,
Yaml,
}
impl std::str::FromStr for ExportFormat {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"toml" => Ok(Self::Toml),
"yaml" | "yml" => Ok(Self::Yaml),
other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other),
}
}
}
impl ExportFormat {
/// Infer format from file extension (.json / .toml / .yaml / .yml).
pub fn from_extension(path: &str) -> anyhow::Result<Self> {
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
ext.parse().map_err(|_| {
anyhow::anyhow!(
"Cannot infer format from extension '.{}'. Use --format json|toml|yaml",
ext
)
})
}
/// Serialize ExportData to a string in this format.
pub fn serialize(&self, data: &ExportData) -> anyhow::Result<String> {
match self {
Self::Json => Ok(serde_json::to_string_pretty(data)?),
Self::Toml => {
let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?;
toml::to_string_pretty(&toml_val)
.map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e))
}
Self::Yaml => serde_yaml::to_string(data)
.map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)),
}
}
/// Deserialize ExportData from a string in this format.
pub fn deserialize(&self, content: &str) -> anyhow::Result<ExportData> {
match self {
Self::Json => Ok(serde_json::from_str(content)?),
Self::Toml => {
let toml_val: toml::Value = toml::from_str(content)
.map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?;
let json_val = toml_to_json_value(&toml_val);
Ok(serde_json::from_value(json_val)?)
}
Self::Yaml => serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)),
}
}
}
/// Top-level structure for export/import files.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportData {
pub version: u32,
pub exported_at: String,
pub entries: Vec<ExportEntry>,
}
/// A single entry with decrypted secrets for export/import.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportEntry {
pub namespace: String,
pub kind: String,
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<BTreeMap<String, Value>>,
}
// ── Multi-user models ──────────────────────────────────────────────────────────
/// A registered user (created on first OAuth login).
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: Option<String>,
pub name: String,
pub avatar_url: Option<String>,
/// PBKDF2 salt (32 B). NULL until user sets up passphrase.
pub key_salt: Option<Vec<u8>>,
/// AES-256-GCM encryption of the known constant "secrets-mcp-key-check".
/// Used to verify the passphrase without storing the key itself.
pub key_check: Option<Vec<u8>>,
/// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}.
pub key_params: Option<serde_json::Value>,
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
pub api_key: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// An OAuth account linked to a user.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct OauthAccount {
pub id: Uuid,
pub user_id: Uuid,
pub provider: String,
pub provider_id: String,
pub email: Option<String>,
pub name: Option<String>,
pub avatar_url: Option<String>,
pub created_at: DateTime<Utc>,
}
/// A single audit log row, optionally scoped to a business user.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct AuditLogEntry {
pub id: i64,
pub user_id: Option<Uuid>,
pub action: String,
pub namespace: String,
pub kind: String,
pub name: String,
pub detail: Value,
pub created_at: DateTime<Utc>,
}
// ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
/// Convert a serde_json Value to a toml Value.
/// `null` values are filtered out (TOML does not support null).
/// Mixed-type arrays are serialised as JSON strings.
pub fn json_to_toml_value(v: &Value) -> anyhow::Result<toml::Value> {
match v {
Value::Null => anyhow::bail!("TOML does not support null values"),
Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(toml::Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(toml::Value::Float(f))
} else {
anyhow::bail!("unsupported number: {}", n)
}
}
Value::String(s) => Ok(toml::Value::String(s.clone())),
Value::Array(arr) => {
let items: anyhow::Result<Vec<toml::Value>> =
arr.iter().map(json_to_toml_value).collect();
match items {
Ok(vals) => Ok(toml::Value::Array(vals)),
Err(e) => {
tracing::debug!(error = %e, "mixed-type array; falling back to JSON string");
Ok(toml::Value::String(serde_json::to_string(v)?))
}
}
}
Value::Object(map) => {
let mut toml_map = toml::map::Map::new();
for (k, val) in map {
if val.is_null() {
// Skip null entries
continue;
}
match json_to_toml_value(val) {
Ok(tv) => {
toml_map.insert(k.clone(), tv);
}
Err(e) => {
tracing::debug!(key = %k, error = %e, "field not representable in TOML; falling back to JSON string");
toml_map
.insert(k.clone(), toml::Value::String(serde_json::to_string(val)?));
}
}
}
Ok(toml::Value::Table(toml_map))
}
}
}
/// Convert a toml Value back to a serde_json Value.
pub fn toml_to_json_value(v: &toml::Value) -> Value {
match v {
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Integer(i) => Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()),
toml::Value::Table(map) => {
let obj: serde_json::Map<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), toml_to_json_value(v)))
.collect();
Value::Object(obj)
}
}
}

View File

@@ -1,405 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use std::fs;
use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::models::EntryRow;
// ── Key/value parsing helpers ─────────────────────────────────────────────────
pub fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
if let Some((key, json_str)) = entry.split_once(":=") {
let val: Value = serde_json::from_str(json_str).map_err(|e| {
anyhow::anyhow!(
"Invalid JSON value for key '{}': {} (use key=value for plain strings)",
key,
e
)
})?;
return Ok((parse_key_path(key)?, val));
}
if let Some((key, raw_val)) = entry.split_once('=') {
let value = if let Some(path) = raw_val.strip_prefix('@') {
fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
} else {
raw_val.to_string()
};
return Ok((parse_key_path(key)?, Value::String(value)));
}
if let Some((key, path)) = entry.split_once('@') {
let value = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?;
return Ok((parse_key_path(key)?, Value::String(value)));
}
anyhow::bail!(
"Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=<json>",
entry
)
}
pub fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new();
for entry in entries {
let (path, value) = parse_kv(entry)?;
insert_path(&mut map, &path, value)?;
}
Ok(Value::Object(map))
}
pub fn key_path_to_string(path: &[String]) -> String {
path.join(":")
}
pub fn collect_key_paths(entries: &[String]) -> Result<Vec<String>> {
entries
.iter()
.map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path)))
.collect()
}
pub fn collect_field_paths(entries: &[String]) -> Result<Vec<String>> {
entries
.iter()
.map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path)))
.collect()
}
pub fn parse_key_path(key: &str) -> Result<Vec<String>> {
let path: Vec<String> = key
.split(':')
.map(str::trim)
.map(ToOwned::to_owned)
.collect();
if path.is_empty() || path.iter().any(|part| part.is_empty()) {
anyhow::bail!(
"Invalid key path '{}'. Use non-empty segments like 'credentials:content'.",
key
);
}
Ok(path)
}
pub fn insert_path(map: &mut Map<String, Value>, path: &[String], value: Value) -> Result<()> {
if path.is_empty() {
anyhow::bail!("Key path cannot be empty");
}
if path.len() == 1 {
map.insert(path[0].clone(), value);
return Ok(());
}
let head = path[0].clone();
let tail = &path[1..];
match map.entry(head.clone()) {
serde_json::map::Entry::Vacant(entry) => {
let mut child = Map::new();
insert_path(&mut child, tail, value)?;
entry.insert(Value::Object(child));
}
serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() {
Value::Object(child) => insert_path(child, tail, value)?,
_ => {
anyhow::bail!(
"Cannot set nested key '{}' because '{}' is already a non-object value",
key_path_to_string(path),
head
);
}
},
}
Ok(())
}
pub fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Result<bool> {
if path.is_empty() {
anyhow::bail!("Key path cannot be empty");
}
if path.len() == 1 {
return Ok(map.remove(&path[0]).is_some());
}
let Some(value) = map.get_mut(&path[0]) else {
return Ok(false);
};
let Value::Object(child) = value else {
return Ok(false);
};
let removed = remove_path(child, &path[1..])?;
if child.is_empty() {
map.remove(&path[0]);
}
Ok(removed)
}
pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)> {
match value {
Value::Object(map) => {
let mut out = Vec::new();
for (k, v) in map {
let full_key = if prefix.is_empty() {
k.clone()
} else {
format!("{}.{}", prefix, k)
};
out.extend(flatten_json_fields(&full_key, v));
}
out
}
other => vec![(prefix.to_string(), other.clone())],
}
}
// ── AddResult ─────────────────────────────────────────────────────────────────
#[derive(Debug, serde::Serialize)]
pub struct AddResult {
pub namespace: String,
pub kind: String,
pub name: String,
pub tags: Vec<String>,
pub meta_keys: Vec<String>,
pub secret_keys: Vec<String>,
}
pub struct AddParams<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub tags: &'a [String],
pub meta_entries: &'a [String],
pub secret_entries: &'a [String],
/// Optional user_id for multi-user isolation (None = single-user CLI mode)
pub user_id: Option<Uuid>,
}
pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> {
let metadata = build_json(params.meta_entries)?;
let secret_json = build_json(params.secret_entries)?;
let meta_keys = collect_key_paths(params.meta_entries)?;
let secret_keys = collect_key_paths(params.secret_entries)?;
let mut tx = pool.begin().await?;
// Fetch existing entry (user-scoped or global depending on user_id)
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4",
)
.bind(uid)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3",
)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
};
if let Some(ref ex) = existing
&& let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: ex.id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
name: params.name,
version: ex.version,
action: "add",
tags: &ex.tags,
metadata: &ex.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
}
let entry_id: Uuid = if let Some(uid) = params.user_id {
sqlx::query_scalar(
r#"INSERT INTO entries (user_id, namespace, kind, name, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
ON CONFLICT (user_id, namespace, kind, name) WHERE user_id IS NOT NULL
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id"#,
)
.bind(uid)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.bind(params.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
r#"INSERT INTO entries (namespace, kind, name, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, 1, NOW())
ON CONFLICT (namespace, kind, name) WHERE user_id IS NULL
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id"#,
)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.bind(params.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?
};
let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
.bind(entry_id)
.fetch_one(&mut *tx)
.await?;
if existing.is_none()
&& let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
name: params.name,
version: new_entry_version,
action: "create",
tags: params.tags,
metadata: &metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history on create");
}
if existing.is_some() {
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
field_name: String,
encrypted: Vec<u8>,
}
let existing_fields: Vec<ExistingField> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.fetch_all(&mut *tx)
.await?;
for f in &existing_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id,
secret_id: f.id,
entry_version: new_entry_version - 1,
field_name: &f.field_name,
encrypted: &f.encrypted,
action: "add",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
}
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.execute(&mut *tx)
.await?;
}
let flat_fields = flatten_json_fields("", &secret_json);
for (field_name, field_value) in &flat_fields {
let encrypted = crypto::encrypt_json(master_key, field_value)?;
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(entry_id)
.bind(field_name)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
}
crate::audit::log_tx(
&mut tx,
params.user_id,
"add",
params.namespace,
params.kind,
params.name,
serde_json::json!({
"tags": params.tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
}),
)
.await;
tx.commit().await?;
Ok(AddResult {
namespace: params.namespace.to_string(),
kind: params.kind.to_string(),
name: params.name.to_string(),
tags: params.tags.to_vec(),
meta_keys,
secret_keys,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_nested_file_shorthand() {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "line1\nline2").unwrap();
let path = f.path().to_str().unwrap().to_string();
let entry = format!("credentials:content@{}", path);
let (path_parts, value) = parse_kv(&entry).unwrap();
assert_eq!(key_path_to_string(&path_parts), "credentials:content");
assert!(matches!(value, Value::String(_)));
}
#[test]
fn flatten_json_fields_nested() {
let v = serde_json::json!({
"username": "root",
"credentials": {
"type": "ssh",
"content": "pem"
}
});
let mut fields = flatten_json_fields("", &v);
fields.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(fields[0].0, "credentials.content");
assert_eq!(fields[1].0, "credentials.type");
assert_eq!(fields[2].0, "username");
}
}

View File

@@ -1,55 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
const KEY_PREFIX: &str = "sk_";
/// Generate a new API key: `sk_<64 hex chars>` = 67 characters total.
pub fn generate_api_key() -> String {
use rand::RngExt;
let mut bytes = [0u8; 32];
rand::rng().fill(&mut bytes);
let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
format!("{}{}", KEY_PREFIX, hex)
}
/// Return the user's existing API key, or generate and store a new one if NULL.
pub async fn ensure_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
let existing: Option<(Option<String>,)> =
sqlx::query_as("SELECT api_key FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?;
if let Some((Some(key),)) = existing {
return Ok(key);
}
let new_key = generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user_id)
.execute(pool)
.await?;
Ok(new_key)
}
/// Generate a fresh API key for the user, replacing the old one.
pub async fn regenerate_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
let new_key = generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user_id)
.execute(pool)
.await?;
Ok(new_key)
}
/// Validate a Bearer token. Returns the `user_id` if the key matches.
pub async fn validate_api_key(pool: &PgPool, raw_key: &str) -> Result<Option<Uuid>> {
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE api_key = $1")
.bind(raw_key)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id,)| id))
}

View File

@@ -1,23 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::AuditLogEntry;
pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<Vec<AuditLogEntry>> {
let limit = limit.clamp(1, 200);
let rows = sqlx::query_as(
"SELECT id, user_id, action, namespace, kind, name, detail, created_at \
FROM audit_log \
WHERE user_id = $1 \
ORDER BY created_at DESC, id DESC \
LIMIT $2",
)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows)
}

View File

@@ -1,322 +0,0 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::models::{EntryRow, SecretFieldRow};
#[derive(Debug, serde::Serialize)]
pub struct DeletedEntry {
pub namespace: String,
pub kind: String,
pub name: String,
}
#[derive(Debug, serde::Serialize)]
pub struct DeleteResult {
pub deleted: Vec<DeletedEntry>,
pub dry_run: bool,
}
pub struct DeleteParams<'a> {
pub namespace: &'a str,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub dry_run: bool,
pub user_id: Option<Uuid>,
}
pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult> {
match params.name {
Some(name) => {
let kind = params
.kind
.ok_or_else(|| anyhow::anyhow!("--kind is required when --name is specified"))?;
delete_one(
pool,
params.namespace,
kind,
name,
params.dry_run,
params.user_id,
)
.await
}
None => {
delete_bulk(
pool,
params.namespace,
params.kind,
params.dry_run,
params.user_id,
)
.await
}
}
}
async fn delete_one(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
if dry_run {
let exists: bool = if let Some(uid) = user_id {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4)",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3)",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_one(pool)
.await?
};
let deleted = if exists {
vec![DeletedEntry {
namespace: namespace.to_string(),
kind: kind.to_string(),
name: name.to_string(),
}]
} else {
vec![]
};
return Ok(DeleteResult {
deleted,
dry_run: true,
});
}
let mut tx = pool.begin().await?;
let row: Option<EntryRow> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
};
let Some(row) = row else {
tx.rollback().await?;
return Ok(DeleteResult {
deleted: vec![],
dry_run: false,
});
};
snapshot_and_delete(&mut tx, namespace, kind, name, &row, user_id).await?;
crate::audit::log_tx(&mut tx, user_id, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?;
Ok(DeleteResult {
deleted: vec![DeletedEntry {
namespace: namespace.to_string(),
kind: kind.to_string(),
name: name.to_string(),
}],
dry_run: false,
})
}
async fn delete_bulk(
pool: &PgPool,
namespace: &str,
kind: Option<&str>,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
#[derive(Debug, sqlx::FromRow)]
struct FullEntryRow {
id: Uuid,
version: i64,
kind: String,
name: String,
metadata: serde_json::Value,
tags: Vec<String>,
}
let rows: Vec<FullEntryRow> = match (user_id, kind) {
(Some(uid), Some(k)) => {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 ORDER BY name",
)
.bind(uid)
.bind(namespace)
.bind(k)
.fetch_all(pool)
.await?
}
(Some(uid), None) => {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE user_id = $1 AND namespace = $2 ORDER BY kind, name",
)
.bind(uid)
.bind(namespace)
.fetch_all(pool)
.await?
}
(None, Some(k)) => {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 ORDER BY name",
)
.bind(namespace)
.bind(k)
.fetch_all(pool)
.await?
}
(None, None) => {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE user_id IS NULL AND namespace = $1 ORDER BY kind, name",
)
.bind(namespace)
.fetch_all(pool)
.await?
}
};
if dry_run {
let deleted = rows
.iter()
.map(|r| DeletedEntry {
namespace: namespace.to_string(),
kind: r.kind.clone(),
name: r.name.clone(),
})
.collect();
return Ok(DeleteResult {
deleted,
dry_run: true,
});
}
let mut deleted = Vec::with_capacity(rows.len());
for row in &rows {
let entry_row = EntryRow {
id: row.id,
version: row.version,
tags: row.tags.clone(),
metadata: row.metadata.clone(),
};
let mut tx = pool.begin().await?;
snapshot_and_delete(
&mut tx, namespace, &row.kind, &row.name, &entry_row, user_id,
)
.await?;
crate::audit::log_tx(
&mut tx,
user_id,
"delete",
namespace,
&row.kind,
&row.name,
json!({"bulk": true}),
)
.await;
tx.commit().await?;
deleted.push(DeletedEntry {
namespace: namespace.to_string(),
kind: row.kind.clone(),
name: row.name.clone(),
});
}
Ok(DeleteResult {
deleted,
dry_run: false,
})
}
async fn snapshot_and_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
namespace: &str,
kind: &str,
name: &str,
row: &EntryRow,
user_id: Option<Uuid>,
) -> Result<()> {
if let Err(e) = db::snapshot_entry_history(
tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id,
namespace,
kind,
name,
version: row.version,
action: "delete",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before delete");
}
let fields: Vec<SecretFieldRow> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(row.id)
.fetch_all(&mut **tx)
.await?;
for f in &fields {
if let Err(e) = db::snapshot_secret_history(
tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: row.version,
field_name: &f.field_name,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret history before delete");
}
}
sqlx::query("DELETE FROM entries WHERE id = $1")
.bind(row.id)
.execute(&mut **tx)
.await?;
Ok(())
}

View File

@@ -1,122 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
use crate::crypto;
use crate::models::Entry;
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
/// Build an env variable map from entry secrets (for dry-run preview or injection).
#[allow(clippy::too_many_arguments)]
pub async fn build_env_map(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
only_fields: &[String],
prefix: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, String>> {
let entries = fetch_entries(pool, namespace, kind, name, tags, None, user_id).await?;
let mut combined: HashMap<String, String> = HashMap::new();
for entry in &entries {
let entry_map = build_entry_env_map(pool, entry, only_fields, prefix, master_key).await?;
combined.extend(entry_map);
}
Ok(combined)
}
async fn build_entry_env_map(
pool: &PgPool,
entry: &Entry,
only_fields: &[String],
prefix: &str,
master_key: &[u8; 32],
) -> Result<HashMap<String, String>> {
let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
let fields: Vec<_> = if only_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| only_fields.contains(&f.field_name))
.collect()
};
let effective_prefix = env_prefix(entry, prefix);
let mut map = HashMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
let key = format!(
"{}_{}",
effective_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_to_env_string(&decrypted));
}
// Resolve key_ref
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
let key_entries = fetch_entries(
pool,
Some(&entry.namespace),
Some("key"),
Some(key_ref),
&[],
None,
None,
)
.await?;
if let Some(key_entry) = key_entries.first() {
let key_ids = vec![key_entry.id];
let key_fields_map = fetch_secrets_for_entries(pool, &key_ids).await?;
let empty = vec![];
let key_fields = key_fields_map.get(&key_entry.id).unwrap_or(&empty);
let key_prefix = env_prefix(key_entry, prefix);
for f in key_fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
let key_var = format!(
"{}_{}",
key_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key_var, json_to_env_string(&decrypted));
}
} else {
tracing::warn!(key_ref, "key_ref target not found");
}
}
Ok(map)
}
fn env_prefix(entry: &Entry, prefix: &str) -> String {
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
name_part
} else {
let normalized = prefix.to_uppercase().replace(['-', '.', ' '], "_");
let normalized = normalized.trim_end_matches('_');
format!("{}_{}", normalized, name_part)
}
}
fn json_to_env_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}

View File

@@ -1,139 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::{BTreeMap, HashMap};
use uuid::Uuid;
use crate::crypto;
use crate::models::{ExportData, ExportEntry, ExportFormat};
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
pub struct ExportParams<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
pub no_secrets: bool,
pub user_id: Option<Uuid>,
}
pub async fn export(
pool: &PgPool,
params: ExportParams<'_>,
master_key: Option<&[u8; 32]>,
) -> Result<ExportData> {
let entries = fetch_entries(
pool,
params.namespace,
params.kind,
params.name,
params.tags,
params.query,
params.user_id,
)
.await?;
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
let secrets_map: HashMap<Uuid, Vec<_>> = if !params.no_secrets && !entry_ids.is_empty() {
fetch_secrets_for_entries(pool, &entry_ids).await?
} else {
HashMap::new()
};
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
for entry in &entries {
let secrets = if params.no_secrets {
None
} else {
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
if fields.is_empty() {
Some(BTreeMap::new())
} else {
let mk = master_key
.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
let mut map = BTreeMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
}
Some(map)
}
};
export_entries.push(ExportEntry {
namespace: entry.namespace.clone(),
kind: entry.kind.clone(),
name: entry.name.clone(),
tags: entry.tags.clone(),
metadata: entry.metadata.clone(),
secrets,
});
}
Ok(ExportData {
version: 1,
exported_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
entries: export_entries,
})
}
pub async fn export_to_file(
pool: &PgPool,
params: ExportParams<'_>,
master_key: Option<&[u8; 32]>,
file_path: &str,
format_override: Option<&str>,
) -> Result<usize> {
let format = if let Some(f) = format_override {
f.parse::<ExportFormat>()?
} else {
ExportFormat::from_extension(file_path).unwrap_or(ExportFormat::Json)
};
let data = export(pool, params, master_key).await?;
let count = data.entries.len();
let serialized = format.serialize(&data)?;
std::fs::write(file_path, &serialized)?;
Ok(count)
}
pub async fn export_to_string(
pool: &PgPool,
params: ExportParams<'_>,
master_key: Option<&[u8; 32]>,
format: &str,
) -> Result<String> {
let fmt = format.parse::<ExportFormat>()?;
let data = export(pool, params, master_key).await?;
fmt.serialize(&data)
}
// ── Build helpers for re-encoding values as CLI-style entries ─────────────────
pub fn build_meta_entries(metadata: &Value) -> Vec<String> {
let mut entries = Vec::new();
if let Some(obj) = metadata.as_object() {
for (k, v) in obj {
entries.push(value_to_kv_entry(k, v));
}
}
entries
}
pub fn build_secret_entries(secrets: Option<&BTreeMap<String, Value>>) -> Vec<String> {
let mut entries = Vec::new();
if let Some(map) = secrets {
for (k, v) in map {
entries.push(value_to_kv_entry(k, v));
}
}
entries
}
pub fn value_to_kv_entry(key: &str, value: &Value) -> String {
match value {
Value::String(s) => format!("{}={}", key, s),
other => format!("{}:={}", key, other),
}
}

View File

@@ -1,79 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
use crate::crypto;
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
/// Decrypt a single named field from an entry.
pub async fn get_secret_field(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
field_name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<Value> {
let entries = fetch_entries(
pool,
Some(namespace),
Some(kind),
Some(name),
&[],
None,
user_id,
)
.await?;
let entry = entries
.first()
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
let field = fields
.iter()
.find(|f| f.field_name == field_name)
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
crypto::decrypt_json(master_key, &field.encrypted)
}
/// Decrypt all secret fields from an entry. Returns a map field_name → decrypted Value.
pub async fn get_all_secrets(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, Value>> {
let entries = fetch_entries(
pool,
Some(namespace),
Some(kind),
Some(name),
&[],
None,
user_id,
)
.await?;
let entry = entries
.first()
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
let mut map = HashMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
}
Ok(map)
}

View File

@@ -1,75 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, serde::Serialize)]
pub struct HistoryEntry {
pub version: i64,
pub action: String,
pub created_at: String,
}
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
limit: u32,
user_id: Option<Uuid>,
) -> Result<Vec<HistoryEntry>> {
#[derive(sqlx::FromRow)]
struct Row {
version: i64,
action: String,
created_at: chrono::DateTime<chrono::Utc>,
}
let rows: Vec<Row> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT version, action, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \
ORDER BY id DESC LIMIT $5",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(uid)
.bind(limit as i64)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT version, action, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NULL \
ORDER BY id DESC LIMIT $4",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(limit as i64)
.fetch_all(pool)
.await?
};
Ok(rows
.into_iter()
.map(|r| HistoryEntry {
version: r.version,
action: r.action,
created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
.collect())
}
pub async fn run_json(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
limit: u32,
user_id: Option<Uuid>,
) -> Result<Value> {
let entries = run(pool, namespace, kind, name, limit, user_id).await?;
Ok(serde_json::to_value(entries)?)
}

View File

@@ -1,123 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::ExportFormat;
use crate::service::add::{AddParams, run as add_run};
use crate::service::export::{build_meta_entries, build_secret_entries};
#[derive(Debug, serde::Serialize)]
pub struct ImportSummary {
pub total: usize,
pub inserted: usize,
pub skipped: usize,
pub failed: usize,
pub dry_run: bool,
}
pub struct ImportParams<'a> {
pub file: &'a str,
pub force: bool,
pub dry_run: bool,
pub user_id: Option<Uuid>,
}
pub async fn run(
pool: &PgPool,
params: ImportParams<'_>,
master_key: &[u8; 32],
) -> Result<ImportSummary> {
let format = ExportFormat::from_extension(params.file)?;
let content = std::fs::read_to_string(params.file)
.map_err(|e| anyhow::anyhow!("Cannot read file '{}': {}", params.file, e))?;
let data = format.deserialize(&content)?;
if data.version != 1 {
anyhow::bail!(
"Unsupported export version {}. Only version 1 is supported.",
data.version
);
}
let total = data.entries.len();
let mut inserted = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
for entry in &data.entries {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NOT DISTINCT FROM $4)",
)
.bind(&entry.namespace)
.bind(&entry.kind)
.bind(&entry.name)
.bind(params.user_id)
.fetch_one(pool)
.await
.unwrap_or(false);
if exists && !params.force {
return Err(anyhow::anyhow!(
"Import aborted: conflict on [{}/{}/{}]",
entry.namespace,
entry.kind,
entry.name
));
}
if params.dry_run {
if exists {
skipped += 1;
} else {
inserted += 1;
}
continue;
}
let secret_entries = build_secret_entries(entry.secrets.as_ref());
let meta_entries = build_meta_entries(&entry.metadata);
match add_run(
pool,
AddParams {
namespace: &entry.namespace,
kind: &entry.kind,
name: &entry.name,
tags: &entry.tags,
meta_entries: &meta_entries,
secret_entries: &secret_entries,
user_id: params.user_id,
},
master_key,
)
.await
{
Ok(_) => {
inserted += 1;
}
Err(e) => {
tracing::error!(
namespace = entry.namespace,
kind = entry.kind,
name = entry.name,
error = %e,
"failed to import entry"
);
failed += 1;
}
}
}
if failed > 0 {
return Err(anyhow::anyhow!("{} record(s) failed to import", failed));
}
Ok(ImportSummary {
total,
inserted,
skipped,
failed,
dry_run: params.dry_run,
})
}

View File

@@ -1,13 +0,0 @@
pub mod add;
pub mod api_key;
pub mod audit_log;
pub mod delete;
pub mod env_map;
pub mod export;
pub mod get_secret;
pub mod history;
pub mod import;
pub mod rollback;
pub mod search;
pub mod update;
pub mod user;

View File

@@ -1,297 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::crypto;
use crate::db;
#[derive(Debug, serde::Serialize)]
pub struct RollbackResult {
pub namespace: String,
pub kind: String,
pub name: String,
pub restored_version: i64,
}
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
to_version: Option<i64>,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<RollbackResult> {
#[derive(sqlx::FromRow)]
struct EntryHistoryRow {
entry_id: Uuid,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
}
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
if let Some(uid) = user_id {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
AND user_id = $5 ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(ver)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(ver)
.fetch_optional(pool)
.await?
}
} else if let Some(uid) = user_id {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
AND user_id = $4 ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(pool)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for [{}/{}] {}{}.",
namespace,
kind,
name,
to_version
.map(|v| format!(" at version {}", v))
.unwrap_or_default()
)
})?;
#[derive(sqlx::FromRow)]
struct SecretHistoryRow {
field_name: String,
encrypted: Vec<u8>,
action: String,
}
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
"SELECT field_name, encrypted, action FROM secrets_history \
WHERE entry_id = $1 AND entry_version = $2 ORDER BY field_name",
)
.bind(snap.entry_id)
.bind(snap.version)
.fetch_all(pool)
.await?;
for f in &field_snaps {
if f.action != "delete" && !f.encrypted.is_empty() {
crypto::decrypt_json(master_key, &f.encrypted).map_err(|e| {
anyhow::anyhow!(
"Cannot decrypt snapshot for field '{}': {}",
f.field_name,
e
)
})?;
}
}
let mut tx = pool.begin().await?;
#[derive(sqlx::FromRow)]
struct LiveEntry {
id: Uuid,
version: i64,
tags: Vec<String>,
metadata: Value,
}
// Query live entry with correct user_id scoping to avoid PK conflicts
let live: Option<LiveEntry> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
};
let entry_id = if let Some(ref lr) = live {
// Snapshot current state before overwriting
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: lr.id,
user_id,
namespace,
kind,
name,
version: lr.version,
action: "rollback",
tags: &lr.tags,
metadata: &lr.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry before rollback");
}
#[derive(sqlx::FromRow)]
struct LiveField {
id: Uuid,
field_name: String,
encrypted: Vec<u8>,
}
let live_fields: Vec<LiveField> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(lr.id)
.fetch_all(&mut *tx)
.await?;
for f in &live_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: lr.id,
secret_id: f.id,
entry_version: lr.version,
field_name: &f.field_name,
encrypted: &f.encrypted,
action: "rollback",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field before rollback");
}
}
// Update the existing row in-place to preserve its primary key and user_id
sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \
updated_at = NOW() WHERE id = $3",
)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(lr.id)
.execute(&mut *tx)
.await?;
lr.id
} else {
// No live entry — insert a fresh one with a new UUID
if let Some(uid) = user_id {
sqlx::query_scalar(
"INSERT INTO entries \
(user_id, namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING id",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
"INSERT INTO entries \
(namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING id",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
}
};
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.execute(&mut *tx)
.await?;
for f in &field_snaps {
if f.action == "delete" {
continue;
}
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(entry_id)
.bind(&f.field_name)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;
}
crate::audit::log_tx(
&mut tx,
user_id,
"rollback",
namespace,
kind,
name,
serde_json::json!({
"restored_version": snap.version,
"original_action": snap.action,
}),
)
.await;
tx.commit().await?;
Ok(RollbackResult {
namespace: namespace.to_string(),
kind: kind.to_string(),
name: name.to_string(),
restored_version: snap.version,
})
}

View File

@@ -1,241 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
use crate::models::{Entry, SecretField};
pub const FETCH_ALL_LIMIT: u32 = 100_000;
pub struct SearchParams<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
pub sort: &'a str,
pub limit: u32,
pub offset: u32,
/// Multi-user: filter by this user_id. None = single-user / no filter.
pub user_id: Option<Uuid>,
}
#[derive(Debug, serde::Serialize)]
pub struct SearchResult {
pub entries: Vec<Entry>,
pub secret_schemas: HashMap<Uuid, Vec<SecretField>>,
}
pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult> {
let entries = fetch_entries_paged(pool, &params).await?;
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
let secret_schemas = if !entry_ids.is_empty() {
fetch_secret_schemas(pool, &entry_ids).await?
} else {
HashMap::new()
};
Ok(SearchResult {
entries,
secret_schemas,
})
}
/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT.
pub async fn fetch_entries(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
user_id: Option<Uuid>,
) -> Result<Vec<Entry>> {
let params = SearchParams {
namespace,
kind,
name,
tags,
query,
sort: "name",
limit: FETCH_ALL_LIMIT,
offset: 0,
user_id,
};
fetch_entries_paged(pool, &params).await
}
async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<Entry>> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
// user_id filtering — always comes first when present
if a.user_id.is_some() {
conditions.push(format!("user_id = ${}", idx));
idx += 1;
} else {
conditions.push("user_id IS NULL".to_string());
}
if a.namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
idx += 1;
}
if a.kind.is_some() {
conditions.push(format!("kind = ${}", idx));
idx += 1;
}
if a.name.is_some() {
conditions.push(format!("name = ${}", idx));
idx += 1;
}
if !a.tags.is_empty() {
let placeholders: Vec<String> = a
.tags
.iter()
.map(|_| {
let p = format!("${}", idx);
idx += 1;
p
})
.collect();
conditions.push(format!(
"tags @> ARRAY[{}]::text[]",
placeholders.join(", ")
));
}
if a.query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} ESCAPE '\\' OR namespace ILIKE ${i} ESCAPE '\\' \
OR kind ILIKE ${i} ESCAPE '\\' OR metadata::text ILIKE ${i} ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))",
i = idx
));
idx += 1;
}
let order = match a.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
_ => "name ASC",
};
let limit_idx = idx;
idx += 1;
let offset_idx = idx;
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT id, user_id, \
namespace, kind, name, tags, metadata, version, created_at, updated_at \
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
);
let mut q = sqlx::query_as::<_, EntryRaw>(&sql);
if let Some(uid) = a.user_id {
q = q.bind(uid);
}
if let Some(v) = a.namespace {
q = q.bind(v);
}
if let Some(v) = a.kind {
q = q.bind(v);
}
if let Some(v) = a.name {
q = q.bind(v);
}
for tag in a.tags {
q = q.bind(tag);
}
if let Some(v) = a.query {
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_"));
q = q.bind(pattern);
}
q = q.bind(a.limit as i64).bind(a.offset as i64);
let rows = q.fetch_all(pool).await?;
Ok(rows.into_iter().map(Entry::from).collect())
}
/// Fetch secret field names for a set of entry ids (no decryption).
pub async fn fetch_secret_schemas(
pool: &PgPool,
entry_ids: &[Uuid],
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
)
.bind(entry_ids)
.fetch_all(pool)
.await?;
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
}
Ok(map)
}
/// Fetch all secret fields (including encrypted bytes) for a set of entry ids.
pub async fn fetch_secrets_for_entries(
pool: &PgPool,
entry_ids: &[Uuid],
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
)
.bind(entry_ids)
.fetch_all(pool)
.await?;
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
}
Ok(map)
}
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
#[derive(sqlx::FromRow)]
struct EntryRaw {
id: Uuid,
user_id: Option<Uuid>,
namespace: String,
kind: String,
name: String,
tags: Vec<String>,
metadata: Value,
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<EntryRaw> for Entry {
fn from(r: EntryRaw) -> Self {
Entry {
id: r.id,
user_id: r.user_id,
namespace: r.namespace,
kind: r.kind,
name: r.name,
tags: r.tags,
metadata: r.metadata,
version: r.version,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}

View File

@@ -1,273 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::models::EntryRow;
use crate::service::add::{
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
parse_kv, remove_path,
};
#[derive(Debug, serde::Serialize)]
pub struct UpdateResult {
pub namespace: String,
pub kind: String,
pub name: String,
pub add_tags: Vec<String>,
pub remove_tags: Vec<String>,
pub meta_keys: Vec<String>,
pub remove_meta: Vec<String>,
pub secret_keys: Vec<String>,
pub remove_secrets: Vec<String>,
}
pub struct UpdateParams<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub add_tags: &'a [String],
pub remove_tags: &'a [String],
pub meta_entries: &'a [String],
pub remove_meta: &'a [String],
pub secret_entries: &'a [String],
pub remove_secrets: &'a [String],
pub user_id: Option<Uuid>,
}
pub async fn run(
pool: &PgPool,
params: UpdateParams<'_>,
master_key: &[u8; 32],
) -> Result<UpdateResult> {
let mut tx = pool.begin().await?;
let row: Option<EntryRow> = if let Some(uid) = params.user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
)
.bind(uid)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
};
let row = row.ok_or_else(|| {
anyhow::anyhow!(
"Not found: [{}/{}] {}. Use `add` to create it first.",
params.namespace,
params.kind,
params.name
)
})?;
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
name: params.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before update");
}
let mut tags: Vec<String> = row.tags.clone();
for t in params.add_tags {
if !tags.contains(t) {
tags.push(t.clone());
}
}
tags.retain(|t| !params.remove_tags.contains(t));
let mut meta_map: Map<String, Value> = match row.metadata.clone() {
Value::Object(m) => m,
_ => Map::new(),
};
for entry in params.meta_entries {
let (path, value) = parse_kv(entry)?;
insert_path(&mut meta_map, &path, value)?;
}
for key in params.remove_meta {
let path = parse_key_path(key)?;
remove_path(&mut meta_map, &path)?;
}
let metadata = Value::Object(meta_map);
let result = sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, updated_at = NOW() \
WHERE id = $3 AND version = $4",
)
.bind(&tags)
.bind(&metadata)
.bind(row.id)
.bind(row.version)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
tx.rollback().await?;
anyhow::bail!(
"Concurrent modification detected for [{}/{}] {}. Please retry.",
params.namespace,
params.kind,
params.name
);
}
let new_version = row.version + 1;
for entry in params.secret_entries {
let (path, field_value) = parse_kv(entry)?;
let flat = flatten_json_fields("", &{
let mut m = Map::new();
insert_path(&mut m, &path, field_value)?;
Value::Object(m)
});
for (field_name, fv) in &flat {
let encrypted = crypto::encrypt_json(master_key, fv)?;
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
encrypted: Vec<u8>,
}
let ef: Option<ExistingField> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
.bind(field_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(ef) = &ef
&& let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: ef.id,
entry_version: row.version,
field_name,
encrypted: &ef.encrypted,
action: "update",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
encrypted = EXCLUDED.encrypted, version = secrets.version + 1, updated_at = NOW()",
)
.bind(row.id)
.bind(field_name)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
}
}
for key in params.remove_secrets {
let path = parse_key_path(key)?;
let field_name = path.join(".");
#[derive(sqlx::FromRow)]
struct FieldToDelete {
id: Uuid,
encrypted: Vec<u8>,
}
let field: Option<FieldToDelete> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
.bind(&field_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(f) = field {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: new_version,
field_name: &field_name,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
}
sqlx::query("DELETE FROM secrets WHERE id = $1")
.bind(f.id)
.execute(&mut *tx)
.await?;
}
}
let meta_keys = collect_key_paths(params.meta_entries)?;
let remove_meta_keys = collect_field_paths(params.remove_meta)?;
let secret_keys = collect_key_paths(params.secret_entries)?;
let remove_secret_keys = collect_field_paths(params.remove_secrets)?;
crate::audit::log_tx(
&mut tx,
params.user_id,
"update",
params.namespace,
params.kind,
params.name,
serde_json::json!({
"add_tags": params.add_tags,
"remove_tags": params.remove_tags,
"meta_keys": meta_keys,
"remove_meta": remove_meta_keys,
"secret_keys": secret_keys,
"remove_secrets": remove_secret_keys,
}),
)
.await;
tx.commit().await?;
Ok(UpdateResult {
namespace: params.namespace.to_string(),
kind: params.kind.to_string(),
name: params.name.to_string(),
add_tags: params.add_tags.to_vec(),
remove_tags: params.remove_tags.to_vec(),
meta_keys,
remove_meta: remove_meta_keys,
secret_keys,
remove_secrets: remove_secret_keys,
})
}

View File

@@ -1,213 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{OauthAccount, User};
pub struct OAuthProfile {
pub provider: String,
pub provider_id: String,
pub email: Option<String>,
pub name: Option<String>,
pub avatar_url: Option<String>,
}
/// Find or create a user from an OAuth profile.
/// Returns (user, is_new) where is_new indicates first-time registration.
pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result<(User, bool)> {
// Check if this OAuth account already exists
let existing: Option<OauthAccount> = sqlx::query_as(
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
)
.bind(&profile.provider)
.bind(&profile.provider_id)
.fetch_optional(pool)
.await?;
if let Some(oa) = existing {
let user: User = sqlx::query_as(
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
FROM users WHERE id = $1",
)
.bind(oa.user_id)
.fetch_one(pool)
.await?;
return Ok((user, false));
}
// New user — create records (no key yet; user sets passphrase on dashboard)
let display_name = profile
.name
.clone()
.unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string()));
let mut tx = pool.begin().await?;
let user: User = sqlx::query_as(
"INSERT INTO users (email, name, avatar_url) \
VALUES ($1, $2, $3) \
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at",
)
.bind(&profile.email)
.bind(&display_name)
.bind(&profile.avatar_url)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user.id)
.bind(&profile.provider)
.bind(&profile.provider_id)
.bind(&profile.email)
.bind(&profile.name)
.bind(&profile.avatar_url)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok((user, true))
}
/// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup.
pub async fn update_user_key_setup(
pool: &PgPool,
user_id: Uuid,
key_salt: &[u8],
key_check: &[u8],
key_params: &Value,
) -> Result<()> {
sqlx::query(
"UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \
WHERE id = $4",
)
.bind(key_salt)
.bind(key_check)
.bind(key_params)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
/// Fetch a user by ID.
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
let user = sqlx::query_as(
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(user)
}
/// List all OAuth accounts linked to a user.
pub async fn list_oauth_accounts(pool: &PgPool, user_id: Uuid) -> Result<Vec<OauthAccount>> {
let accounts = sqlx::query_as(
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
FROM oauth_accounts WHERE user_id = $1 ORDER BY created_at",
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(accounts)
}
/// Bind an additional OAuth account to an existing user.
pub async fn bind_oauth_account(
pool: &PgPool,
user_id: Uuid,
profile: OAuthProfile,
) -> Result<OauthAccount> {
// Check if this provider_id is already linked to someone else
let conflict: Option<(Uuid,)> = sqlx::query_as(
"SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
)
.bind(&profile.provider)
.bind(&profile.provider_id)
.fetch_optional(pool)
.await?;
if let Some((existing_user_id,)) = conflict {
if existing_user_id != user_id {
anyhow::bail!(
"This {} account is already linked to a different user",
profile.provider
);
}
anyhow::bail!(
"This {} account is already linked to your account",
profile.provider
);
}
let existing_provider_for_user: Option<(String,)> = sqlx::query_as(
"SELECT provider_id FROM oauth_accounts WHERE user_id = $1 AND provider = $2",
)
.bind(user_id)
.bind(&profile.provider)
.fetch_optional(pool)
.await?;
if existing_provider_for_user.is_some() {
anyhow::bail!(
"You already linked a {} account. Unlink the other provider instead of binding multiple {} accounts.",
profile.provider,
profile.provider
);
}
let account: OauthAccount = sqlx::query_as(
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
VALUES ($1, $2, $3, $4, $5, $6) \
RETURNING id, user_id, provider, provider_id, email, name, avatar_url, created_at",
)
.bind(user_id)
.bind(&profile.provider)
.bind(&profile.provider_id)
.bind(&profile.email)
.bind(&profile.name)
.bind(&profile.avatar_url)
.fetch_one(pool)
.await?;
Ok(account)
}
/// Unbind an OAuth account. Ensures at least one remains and blocks unlinking the current login provider.
pub async fn unbind_oauth_account(
pool: &PgPool,
user_id: Uuid,
provider: &str,
current_login_provider: Option<&str>,
) -> Result<()> {
if current_login_provider == Some(provider) {
anyhow::bail!(
"Cannot unlink the {} account you are currently using to sign in",
provider
);
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_accounts WHERE user_id = $1")
.bind(user_id)
.fetch_one(pool)
.await?;
if count <= 1 {
anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first.");
}
sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2")
.bind(user_id)
.bind(provider)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -1,44 +0,0 @@
[package]
name = "secrets-mcp"
version = "0.1.10"
edition.workspace = true
[[bin]]
name = "secrets-mcp"
path = "src/main.rs"
[dependencies]
secrets-core = { path = "../secrets-core" }
# MCP
rmcp = { version = "1", features = ["server", "macros", "transport-streamable-http-server", "schemars"] }
# Web framework
axum = "0.8"
axum-extra = { version = "0.10", features = ["typed-header"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
tower-sessions = "0.14"
# OAuth (manual token exchange via reqwest)
reqwest.workspace = true
# Templating - render templates manually to avoid integration crate issues
askama = "0.13"
# Common
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
rand.workspace = true
sqlx.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
dotenvy.workspace = true
urlencoding = "2"
schemars = "1"
http = "1"

View File

@@ -1,114 +0,0 @@
use std::net::SocketAddr;
use axum::{
extract::{ConnectInfo, Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use sqlx::PgPool;
use uuid::Uuid;
use secrets_core::service::api_key::validate_api_key;
/// Injected into request extensions after Bearer token validation.
#[derive(Clone, Debug)]
pub struct AuthUser {
pub user_id: Uuid,
}
fn log_client_ip(req: &Request) -> Option<String> {
if let Some(first) = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
{
let s = first.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.ip().to_string())
}
/// Axum middleware that validates Bearer API keys for the /mcp route.
/// Passes all non-MCP paths through without authentication.
pub async fn bearer_auth_middleware(
State(pool): State<PgPool>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let path = req.uri().path();
let method = req.method().as_str();
let client_ip = log_client_ip(&req);
// Only authenticate /mcp paths
if !path.starts_with("/mcp") {
return Ok(next.run(req).await);
}
// Allow OPTIONS (CORS preflight) through
if req.method() == axum::http::Method::OPTIONS {
return Ok(next.run(req).await);
}
let auth_header = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
let raw_key = match auth_header {
Some(h) if h.starts_with("Bearer ") => h.trim_start_matches("Bearer ").trim(),
Some(_) => {
tracing::warn!(
method,
path,
client_ip = client_ip.as_deref(),
"invalid Authorization header format on /mcp (expected Bearer …)"
);
return Err(StatusCode::UNAUTHORIZED);
}
None => {
tracing::warn!(
method,
path,
client_ip = client_ip.as_deref(),
"missing Authorization header on /mcp"
);
return Err(StatusCode::UNAUTHORIZED);
}
};
match validate_api_key(&pool, raw_key).await {
Ok(Some(user_id)) => {
tracing::debug!(?user_id, "api key authenticated");
let mut req = req;
req.extensions_mut().insert(AuthUser { user_id });
Ok(next.run(req).await)
}
Ok(None) => {
tracing::warn!(
method,
path,
client_ip = client_ip.as_deref(),
key_prefix = %&raw_key.chars().take(12).collect::<String>(),
key_len = raw_key.len(),
"invalid api key (not found in database — e.g. revoked key or DB was reset; update MCP client Bearer token)"
);
Err(StatusCode::UNAUTHORIZED)
}
Err(e) => {
tracing::error!(
method,
path,
client_ip = client_ip.as_deref(),
error = %e,
"api key validation error"
);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}

View File

@@ -1,155 +0,0 @@
mod auth;
mod oauth;
mod tools;
mod web;
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::Router;
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
};
use sqlx::PgPool;
use tower_http::cors::{Any, CorsLayer};
use tower_sessions::cookie::SameSite;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use tracing_subscriber::EnvFilter;
use secrets_core::config::resolve_db_url;
use secrets_core::db::{create_pool, migrate};
use crate::oauth::OAuthConfig;
use crate::tools::SecretsService;
/// Shared application state injected into web routes and middleware.
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub google_config: Option<OAuthConfig>,
pub base_url: String,
pub http_client: reqwest::Client,
}
fn load_env_var(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.is_empty())
}
fn load_oauth_config(prefix: &str, base_url: &str, path: &str) -> Option<OAuthConfig> {
let client_id = load_env_var(&format!("{}_CLIENT_ID", prefix))?;
let client_secret = load_env_var(&format!("{}_CLIENT_SECRET", prefix))?;
Some(OAuthConfig {
client_id,
client_secret,
redirect_uri: format!("{}{}", base_url, path),
})
}
#[tokio::main]
async fn main() -> Result<()> {
// Load .env if present
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "secrets_mcp=info".into()),
)
.init();
// ── Database ──────────────────────────────────────────────────────────────
let db_url = resolve_db_url("")
.context("Database not configured. Set SECRETS_DATABASE_URL environment variable.")?;
let pool = create_pool(&db_url)
.await
.context("failed to connect to database")?;
migrate(&pool)
.await
.context("failed to run database migrations")?;
tracing::info!("Database connected and migrated");
// ── Configuration ─────────────────────────────────────────────────────────
let base_url = load_env_var("BASE_URL").unwrap_or_else(|| "http://localhost:9315".to_string());
let bind_addr = load_env_var("SECRETS_MCP_BIND").unwrap_or_else(|| "0.0.0.0:9315".to_string());
// ── OAuth providers ───────────────────────────────────────────────────────
let google_config = load_oauth_config("GOOGLE", &base_url, "/auth/google/callback");
if google_config.is_none() {
tracing::warn!(
"No OAuth providers configured. Set GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET to enable login."
);
}
// ── Session store ─────────────────────────────────────────────────────────
let session_store = MemoryStore::default();
// Strict would drop the session cookie on redirect from Google → our origin (cross-site nav).
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(base_url.starts_with("https://"))
.with_same_site(SameSite::Lax);
// ── App state ─────────────────────────────────────────────────────────────
let app_state = AppState {
pool: pool.clone(),
google_config,
base_url: base_url.clone(),
http_client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.context("failed to build HTTP client")?,
};
// ── MCP service ───────────────────────────────────────────────────────────
let pool_arc = Arc::new(pool.clone());
let mcp_service = StreamableHttpService::new(
move || {
let p = pool_arc.clone();
Ok(SecretsService::new(p))
},
LocalSessionManager::default().into(),
Default::default(),
);
// ── Router ────────────────────────────────────────────────────────────────
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let router = Router::new()
.merge(web::web_router())
.nest_service("/mcp", mcp_service)
.layer(axum::middleware::from_fn_with_state(
pool,
auth::bearer_auth_middleware,
))
.layer(session_layer)
.layer(cors)
.with_state(app_state);
// ── Start server ──────────────────────────────────────────────────────────
let listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.with_context(|| format!("failed to bind to {}", bind_addr))?;
tracing::info!("Secrets MCP Server listening on http://{}", bind_addr);
tracing::info!("MCP endpoint: {}/mcp", base_url);
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.context("server error")?;
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to install CTRL+C signal handler");
tracing::info!("Shutting down gracefully...");
}

View File

@@ -1,66 +0,0 @@
use anyhow::{Context, Result};
use serde::Deserialize;
use super::{OAuthConfig, OAuthUserInfo};
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[allow(dead_code)]
token_type: String,
#[allow(dead_code)]
id_token: Option<String>,
}
#[derive(Deserialize)]
struct UserInfo {
sub: String,
email: Option<String>,
name: Option<String>,
picture: Option<String>,
}
/// Exchange authorization code for tokens and fetch user profile.
pub async fn exchange_code(
client: &reqwest::Client,
config: &OAuthConfig,
code: &str,
) -> Result<OAuthUserInfo> {
let token_resp: TokenResponse = client
.post("https://oauth2.googleapis.com/token")
.form(&[
("code", code),
("client_id", &config.client_id),
("client_secret", &config.client_secret),
("redirect_uri", &config.redirect_uri),
("grant_type", "authorization_code"),
])
.send()
.await
.context("failed to exchange Google code")?
.error_for_status()
.context("Google token endpoint error")?
.json()
.await
.context("failed to parse Google token response")?;
let user: UserInfo = client
.get("https://openidconnect.googleapis.com/v1/userinfo")
.bearer_auth(&token_resp.access_token)
.send()
.await
.context("failed to fetch Google userinfo")?
.error_for_status()
.context("Google userinfo endpoint error")?
.json()
.await
.context("failed to parse Google userinfo")?;
Ok(OAuthUserInfo {
provider: "google".to_string(),
provider_id: user.sub,
email: user.email,
name: user.name,
avatar_url: user.picture,
})
}

View File

@@ -1,45 +0,0 @@
pub mod google;
pub mod wechat; // not yet implemented — placeholder for future WeChat integration
use serde::{Deserialize, Serialize};
/// Normalized OAuth user profile from any provider.
#[derive(Debug, Clone)]
pub struct OAuthUserInfo {
pub provider: String,
pub provider_id: String,
pub email: Option<String>,
pub name: Option<String>,
pub avatar_url: Option<String>,
}
/// OAuth provider configuration.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OAuthConfig {
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
}
/// Build the Google authorization URL.
pub fn google_auth_url(config: &OAuthConfig, state: &str) -> String {
format!(
"https://accounts.google.com/o/oauth2/v2/auth\
?client_id={}\
&redirect_uri={}\
&response_type=code\
&scope=openid%20email%20profile\
&state={}\
&access_type=offline",
urlencoding::encode(&config.client_id),
urlencoding::encode(&config.redirect_uri),
urlencoding::encode(state),
)
}
pub fn random_state() -> String {
use rand::RngExt;
let mut bytes = [0u8; 16];
rand::rng().fill(&mut bytes);
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

View File

@@ -1,18 +0,0 @@
use super::{OAuthConfig, OAuthUserInfo};
/// WeChat OAuth — not yet implemented.
///
/// This module is a placeholder for future WeChat Open Platform integration.
/// When ready, implement `exchange_code` following the non-standard WeChat OAuth 2.0 flow:
/// - Token exchange uses a GET request (not POST)
/// - Preferred user identifier is `unionid` (cross-app), falling back to `openid`
/// - Docs: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
use anyhow::{Result, bail};
#[allow(dead_code)]
pub async fn exchange_code(
_client: &reqwest::Client,
_config: &OAuthConfig,
_code: &str,
) -> Result<OAuthUserInfo> {
bail!("WeChat login is not yet implemented")
}

View File

@@ -1,610 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use rmcp::{
RoleServer, ServerHandler,
handler::server::wrapper::Parameters,
model::{
CallToolResult, Content, Implementation, InitializeResult, ProtocolVersion,
ServerCapabilities,
},
service::RequestContext,
tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use secrets_core::service::{
add::{AddParams, run as svc_add},
delete::{DeleteParams, run as svc_delete},
export::{ExportParams, export as svc_export},
get_secret::{get_all_secrets, get_secret_field},
history::run as svc_history,
rollback::run as svc_rollback,
search::{SearchParams, run as svc_search},
update::{UpdateParams, run as svc_update},
};
use crate::auth::AuthUser;
// ── Shared state ──────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct SecretsService {
pub pool: Arc<PgPool>,
pub tool_router: rmcp::handler::server::router::tool::ToolRouter<SecretsService>,
}
impl SecretsService {
pub fn new(pool: Arc<PgPool>) -> Self {
Self {
pool,
tool_router: Self::tool_router(),
}
}
/// Extract user_id from the HTTP request parts injected by auth middleware.
fn user_id_from_ctx(ctx: &RequestContext<RoleServer>) -> Result<Option<Uuid>, rmcp::ErrorData> {
let parts = ctx
.extensions
.get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?;
Ok(parts.extensions.get::<AuthUser>().map(|a| a.user_id))
}
/// Get the authenticated user_id (returns error if not authenticated).
fn require_user_id(ctx: &RequestContext<RoleServer>) -> Result<Uuid, rmcp::ErrorData> {
let parts = ctx
.extensions
.get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?;
parts
.extensions
.get::<AuthUser>()
.map(|a| a.user_id)
.ok_or_else(|| rmcp::ErrorData::invalid_request("Unauthorized: API key required", None))
}
/// Extract the 32-byte encryption key from the X-Encryption-Key request header.
/// The header value must be 64 lowercase hex characters (PBKDF2-derived key).
fn extract_enc_key(ctx: &RequestContext<RoleServer>) -> Result<[u8; 32], rmcp::ErrorData> {
let parts = ctx
.extensions
.get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?;
let hex_str = parts
.headers
.get("x-encryption-key")
.ok_or_else(|| {
rmcp::ErrorData::invalid_request(
"Missing X-Encryption-Key header. \
Set this to your 64-char hex encryption key derived from your passphrase.",
None,
)
})?
.to_str()
.map_err(|_| {
rmcp::ErrorData::invalid_request("Invalid X-Encryption-Key header value", None)
})?;
secrets_core::crypto::extract_key_from_hex(hex_str)
.map_err(|e| rmcp::ErrorData::invalid_request(e.to_string(), None))
}
/// Require both user_id and encryption key.
fn require_user_and_key(
ctx: &RequestContext<RoleServer>,
) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> {
let user_id = Self::require_user_id(ctx)?;
let key = Self::extract_enc_key(ctx)?;
Ok((user_id, key))
}
}
// ── Tool parameter types ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize, JsonSchema)]
struct SearchInput {
#[schemars(description = "Namespace filter (e.g. 'refining', 'ricnsmart')")]
namespace: Option<String>,
#[schemars(description = "Kind filter (e.g. 'server', 'service', 'key')")]
kind: Option<String>,
#[schemars(description = "Exact record name")]
name: Option<String>,
#[schemars(description = "Tag filters (all must match)")]
tags: Option<Vec<String>>,
#[schemars(description = "Fuzzy search across name, namespace, kind, tags, metadata")]
query: Option<String>,
#[schemars(description = "Return only summary fields (name/tags/desc/updated_at)")]
summary: Option<bool>,
#[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
sort: Option<String>,
#[schemars(description = "Max results (default 20)")]
limit: Option<u32>,
#[schemars(description = "Pagination offset (default 0)")]
offset: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GetSecretInput {
#[schemars(description = "Namespace of the entry")]
namespace: String,
#[schemars(description = "Kind of the entry (e.g. 'server', 'service')")]
kind: String,
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(description = "Specific field to retrieve. If omitted, returns all fields.")]
field: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct AddInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind (e.g. 'server', 'service', 'key')")]
kind: String,
#[schemars(description = "Unique name within namespace+kind")]
name: String,
#[schemars(description = "Tags for this entry")]
tags: Option<Vec<String>>,
#[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")]
meta: Option<Vec<String>>,
#[schemars(description = "Secret fields as 'key=value' strings")]
secrets: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct UpdateInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
name: String,
#[schemars(description = "Tags to add")]
add_tags: Option<Vec<String>>,
#[schemars(description = "Tags to remove")]
remove_tags: Option<Vec<String>>,
#[schemars(description = "Metadata fields to update/add as 'key=value' strings")]
meta: Option<Vec<String>>,
#[schemars(description = "Metadata field keys to remove")]
remove_meta: Option<Vec<String>>,
#[schemars(description = "Secret fields to update/add as 'key=value' strings")]
secrets: Option<Vec<String>>,
#[schemars(description = "Secret field keys to remove")]
remove_secrets: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct DeleteInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind filter (required for single delete)")]
kind: Option<String>,
#[schemars(description = "Exact name to delete. Omit for bulk delete by namespace+kind.")]
name: Option<String>,
#[schemars(description = "Preview deletions without writing")]
dry_run: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct HistoryInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
name: String,
#[schemars(description = "Max history entries to return (default 20)")]
limit: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct RollbackInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
name: String,
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
to_version: Option<i64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ExportInput {
#[schemars(description = "Namespace filter")]
namespace: Option<String>,
#[schemars(description = "Kind filter")]
kind: Option<String>,
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
tags: Option<Vec<String>>,
#[schemars(description = "Fuzzy query")]
query: Option<String>,
#[schemars(description = "Export format: 'json' (default), 'toml', 'yaml'")]
format: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct EnvMapInput {
#[schemars(description = "Namespace filter")]
namespace: Option<String>,
#[schemars(description = "Kind filter")]
kind: Option<String>,
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
tags: Option<Vec<String>>,
#[schemars(description = "Only include these secret fields")]
only_fields: Option<Vec<String>>,
#[schemars(description = "Environment variable name prefix")]
prefix: Option<String>,
}
// ── Tool implementations ──────────────────────────────────────────────────────
#[tool_router]
impl SecretsService {
#[tool(
description = "Search entries in the secrets store. Returns entries with metadata and \
secret field names (not values). Use secrets_get to decrypt secret values."
)]
async fn secrets_search(
&self,
Parameters(input): Parameters<SearchInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let user_id = Self::user_id_from_ctx(&ctx)?;
let tags = input.tags.unwrap_or_default();
let result = svc_search(
&self.pool,
SearchParams {
namespace: input.namespace.as_deref(),
kind: input.kind.as_deref(),
name: input.name.as_deref(),
tags: &tags,
query: input.query.as_deref(),
sort: input.sort.as_deref().unwrap_or("name"),
limit: input.limit.unwrap_or(20),
offset: input.offset.unwrap_or(0),
user_id,
},
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let summary = input.summary.unwrap_or(false);
let entries: Vec<serde_json::Value> = result
.entries
.iter()
.map(|e| {
if summary {
serde_json::json!({
"namespace": e.namespace,
"kind": e.kind,
"name": e.name,
"tags": e.tags,
"desc": e.metadata.get("desc").or_else(|| e.metadata.get("url"))
.and_then(|v| v.as_str()).unwrap_or(""),
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
} else {
let schema: Vec<&str> = result
.secret_schemas
.get(&e.id)
.map(|f| f.iter().map(|s| s.field_name.as_str()).collect())
.unwrap_or_default();
serde_json::json!({
"id": e.id,
"namespace": e.namespace,
"kind": e.kind,
"name": e.name,
"tags": e.tags,
"metadata": e.metadata,
"secret_fields": schema,
"version": e.version,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
}
})
.collect();
let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string());
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Get decrypted secret field values for an entry. Requires your \
encryption key via X-Encryption-Key header (64 hex chars, PBKDF2-derived). \
Returns all fields, or a specific field if 'field' is provided."
)]
async fn secrets_get(
&self,
Parameters(input): Parameters<GetSecretInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
if let Some(field_name) = &input.field {
let value = get_secret_field(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
field_name,
&user_key,
Some(user_id),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let result = serde_json::json!({ field_name: value });
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
} else {
let secrets = get_all_secrets(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
&user_key,
Some(user_id),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&secrets).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
}
#[tool(
description = "Add or upsert an entry with metadata and encrypted secret fields. \
Requires X-Encryption-Key header. \
Meta and secret values use 'key=value', 'key=@file', or 'key:=<json>' format."
)]
async fn secrets_add(
&self,
Parameters(input): Parameters<AddInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default();
let meta = input.meta.unwrap_or_default();
let secrets = input.secrets.unwrap_or_default();
let result = svc_add(
&self.pool,
AddParams {
namespace: &input.namespace,
kind: &input.kind,
name: &input.name,
tags: &tags,
meta_entries: &meta,
secret_entries: &secrets,
user_id: Some(user_id),
},
&user_key,
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Incrementally update an existing entry. Requires X-Encryption-Key header. \
Only the fields you specify are changed; everything else is preserved."
)]
async fn secrets_update(
&self,
Parameters(input): Parameters<UpdateInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let add_tags = input.add_tags.unwrap_or_default();
let remove_tags = input.remove_tags.unwrap_or_default();
let meta = input.meta.unwrap_or_default();
let remove_meta = input.remove_meta.unwrap_or_default();
let secrets = input.secrets.unwrap_or_default();
let remove_secrets = input.remove_secrets.unwrap_or_default();
let result = svc_update(
&self.pool,
UpdateParams {
namespace: &input.namespace,
kind: &input.kind,
name: &input.name,
add_tags: &add_tags,
remove_tags: &remove_tags,
meta_entries: &meta,
remove_meta: &remove_meta,
secret_entries: &secrets,
remove_secrets: &remove_secrets,
user_id: Some(user_id),
},
&user_key,
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Delete one entry (specify namespace+kind+name) or bulk delete all \
entries matching namespace+kind. Use dry_run=true to preview."
)]
async fn secrets_delete(
&self,
Parameters(input): Parameters<DeleteInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let user_id = Self::user_id_from_ctx(&ctx)?;
let result = svc_delete(
&self.pool,
DeleteParams {
namespace: &input.namespace,
kind: input.kind.as_deref(),
name: input.name.as_deref(),
dry_run: input.dry_run.unwrap_or(false),
user_id,
},
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "View change history for an entry. Returns a list of versions with \
actions and timestamps."
)]
async fn secrets_history(
&self,
Parameters(input): Parameters<HistoryInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let user_id = Self::user_id_from_ctx(&ctx)?;
let result = svc_history(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
input.limit.unwrap_or(20),
user_id,
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \
Omit to_version to restore the most recent snapshot."
)]
async fn secrets_rollback(
&self,
Parameters(input): Parameters<RollbackInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let result = svc_rollback(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
input.to_version,
&user_key,
Some(user_id),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Export matching entries with decrypted secrets as JSON/TOML/YAML string. \
Requires X-Encryption-Key header. Useful for backup or data migration."
)]
async fn secrets_export(
&self,
Parameters(input): Parameters<ExportInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default();
let format = input.format.as_deref().unwrap_or("json");
let data = svc_export(
&self.pool,
ExportParams {
namespace: input.namespace.as_deref(),
kind: input.kind.as_deref(),
name: input.name.as_deref(),
tags: &tags,
query: input.query.as_deref(),
no_secrets: false,
user_id: Some(user_id),
},
Some(&user_key),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let serialized = format
.parse::<secrets_core::models::ExportFormat>()
.and_then(|fmt| fmt.serialize(&data))
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(serialized)]))
}
#[tool(
description = "Build the environment variable map from entry secrets with decrypted \
plaintext values. Requires X-Encryption-Key header. \
Returns a JSON object of VAR_NAME -> plaintext_value ready for injection."
)]
async fn secrets_env_map(
&self,
Parameters(input): Parameters<EnvMapInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default();
let only_fields = input.only_fields.unwrap_or_default();
let env_map = secrets_core::service::env_map::build_env_map(
&self.pool,
input.namespace.as_deref(),
input.kind.as_deref(),
input.name.as_deref(),
&tags,
&only_fields,
input.prefix.as_deref().unwrap_or(""),
&user_key,
Some(user_id),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&env_map).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
}
// ── ServerHandler ─────────────────────────────────────────────────────────────
#[tool_handler]
impl ServerHandler for SecretsService {
fn get_info(&self) -> InitializeResult {
let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build());
info.server_info = Implementation::new("secrets-mcp", env!("CARGO_PKG_VERSION"));
info.protocol_version = ProtocolVersion::V_2025_03_26;
info.instructions = Some(
"Manage cross-device secrets and configuration securely. \
Data is encrypted with your passphrase-derived key. \
Include your 64-char hex key in the X-Encryption-Key header for all read/write operations. \
Use secrets_search to discover entries (no key needed), \
secrets_get to decrypt secret values, \
and secrets_add/secrets_update to write encrypted secrets."
.to_string(),
);
info
}
}

View File

@@ -1,646 +0,0 @@
use askama::Template;
use chrono::SecondsFormat;
use std::net::SocketAddr;
use axum::{
Json, Router,
body::Body,
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
use uuid::Uuid;
use secrets_core::audit::log_login;
use secrets_core::crypto::hex;
use secrets_core::service::{
api_key::{ensure_api_key, regenerate_api_key},
audit_log::list_for_user,
user::{
OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id,
unbind_oauth_account, update_user_key_setup,
},
};
use crate::AppState;
use crate::oauth::{OAuthConfig, OAuthUserInfo, google_auth_url, random_state};
const SESSION_USER_ID: &str = "user_id";
const SESSION_OAUTH_STATE: &str = "oauth_state";
const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode";
const SESSION_LOGIN_PROVIDER: &str = "login_provider";
// ── Template types ────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
has_google: bool,
version: &'static str,
}
#[derive(Template)]
#[template(path = "dashboard.html")]
struct DashboardTemplate {
user_name: String,
user_email: String,
has_passphrase: bool,
base_url: String,
version: &'static str,
}
#[derive(Template)]
#[template(path = "audit.html")]
struct AuditPageTemplate {
user_name: String,
user_email: String,
entries: Vec<AuditEntryView>,
version: &'static str,
}
struct AuditEntryView {
/// RFC3339 UTC for `<time datetime>`; rendered as browser-local in audit.html.
created_at_iso: String,
action: String,
target: String,
detail: String,
}
// ── App state helpers ─────────────────────────────────────────────────────────
fn google_cfg(state: &AppState) -> Option<&OAuthConfig> {
state.google_config.as_ref()
}
async fn current_user_id(session: &Session) -> Option<Uuid> {
session
.get::<String>(SESSION_USER_ID)
.await
.ok()
.flatten()
.and_then(|s| Uuid::parse_str(&s).ok())
}
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
if let Some(first) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
{
let value = first.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
Some(connect_info.ip().to_string())
}
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
// ── Routes ────────────────────────────────────────────────────────────────────
pub fn web_router() -> Router<AppState> {
Router::new()
.route("/favicon.svg", get(favicon_svg))
.route(
"/favicon.ico",
get(|| async { Redirect::permanent("/favicon.svg") }),
)
.route("/", get(login_page))
.route("/auth/google", get(auth_google))
.route("/auth/google/callback", get(auth_google_callback))
.route("/auth/logout", post(auth_logout))
.route("/dashboard", get(dashboard))
.route("/audit", get(audit_page))
.route("/account/bind/google", get(account_bind_google))
.route(
"/account/bind/google/callback",
get(account_bind_google_callback),
)
.route("/account/unbind/{provider}", post(account_unbind))
.route("/api/key-salt", get(api_key_salt))
.route("/api/key-setup", post(api_key_setup))
.route("/api/apikey", get(api_apikey_get))
.route("/api/apikey/regenerate", post(api_apikey_regenerate))
}
async fn favicon_svg() -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/svg+xml")
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(include_str!("../static/favicon.svg")))
.expect("favicon response")
}
// ── Login page ────────────────────────────────────────────────────────────────
async fn login_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
if let Some(_uid) = current_user_id(&session).await {
return Ok(Redirect::to("/dashboard").into_response());
}
let tmpl = LoginTemplate {
has_google: state.google_config.is_some(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── Google OAuth ──────────────────────────────────────────────────────────────
async fn auth_google(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let config = google_cfg(&state).ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
let oauth_state = random_state();
session
.insert(SESSION_OAUTH_STATE, &oauth_state)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let url = google_auth_url(config, &oauth_state);
Ok(Redirect::to(&url).into_response())
}
#[derive(Deserialize)]
struct OAuthCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
async fn auth_google_callback(
State(state): State<AppState>,
connect_info: ConnectInfo<SocketAddr>,
headers: HeaderMap,
session: Session,
Query(params): Query<OAuthCallbackQuery>,
) -> Result<Response, StatusCode> {
let client_ip = request_client_ip(&headers, connect_info);
let user_agent = request_user_agent(&headers);
handle_oauth_callback(
&state,
&session,
params,
"google",
client_ip.as_deref(),
user_agent.as_deref(),
|s, cfg, code| {
Box::pin(crate::oauth::google::exchange_code(
&s.http_client,
cfg,
code,
))
},
)
.await
}
// ── Shared OAuth callback handler ─────────────────────────────────────────────
async fn handle_oauth_callback<F>(
state: &AppState,
session: &Session,
params: OAuthCallbackQuery,
provider: &str,
client_ip: Option<&str>,
user_agent: Option<&str>,
exchange_fn: F,
) -> Result<Response, StatusCode>
where
F: for<'a> Fn(
&'a AppState,
&'a OAuthConfig,
&'a str,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<OAuthUserInfo>> + Send + 'a>,
>,
{
if let Some(err) = params.error {
tracing::warn!(provider, error = %err, "OAuth error");
return Ok(Redirect::to("/?error=oauth_error").into_response());
}
let Some(code) = params.code else {
tracing::warn!(provider, "OAuth callback missing code");
return Ok(Redirect::to("/?error=oauth_missing_code").into_response());
};
let Some(returned_state) = params.state.as_deref() else {
tracing::warn!(provider, "OAuth callback missing state");
return Ok(Redirect::to("/?error=oauth_missing_state").into_response());
};
let expected_state: Option<String> = session
.get(SESSION_OAUTH_STATE)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if expected_state.as_deref() != Some(returned_state) {
tracing::warn!(
provider,
expected_present = expected_state.is_some(),
"OAuth state mismatch (empty session often means SameSite=Strict or server restart)"
);
return Ok(Redirect::to("/?error=oauth_state").into_response());
}
session.remove::<String>(SESSION_OAUTH_STATE).await.ok();
let config = match provider {
"google" => state
.google_config
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?,
_ => return Err(StatusCode::BAD_REQUEST),
};
let user_info = exchange_fn(state, config, code.as_str())
.await
.map_err(|e| {
tracing::error!(provider, error = %e, "failed to exchange OAuth code");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let bind_mode: bool = session
.get(SESSION_OAUTH_BIND_MODE)
.await
.unwrap_or(None)
.unwrap_or(false);
if bind_mode {
let user_id = current_user_id(session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await.ok();
let profile = OAuthProfile {
provider: user_info.provider,
provider_id: user_info.provider_id,
email: user_info.email,
name: user_info.name,
avatar_url: user_info.avatar_url,
};
bind_oauth_account(&state.pool, user_id, profile)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to bind OAuth account");
StatusCode::INTERNAL_SERVER_ERROR
})?;
return Ok(Redirect::to("/dashboard?bound=1").into_response());
}
let profile = OAuthProfile {
provider: user_info.provider,
provider_id: user_info.provider_id,
email: user_info.email,
name: user_info.name,
avatar_url: user_info.avatar_url,
};
let (user, _is_new) = find_or_create_user(&state.pool, profile)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to find or create user");
StatusCode::INTERNAL_SERVER_ERROR
})?;
session
.insert(SESSION_USER_ID, user.id.to_string())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
session
.insert(SESSION_LOGIN_PROVIDER, &provider)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
log_login(
&state.pool,
"oauth",
provider,
user.id,
client_ip,
user_agent,
)
.await;
Ok(Redirect::to("/dashboard").into_response())
}
// ── Logout ────────────────────────────────────────────────────────────────────
async fn auth_logout(session: Session) -> impl IntoResponse {
session.flush().await.ok();
Redirect::to("/")
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
async fn dashboard(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/").into_response());
};
let user = match get_user_by_id(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
Some(u) => u,
None => return Ok(Redirect::to("/").into_response()),
};
let tmpl = DashboardTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
has_passphrase: user.key_salt.is_some(),
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
async fn audit_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/").into_response());
};
let user = match get_user_by_id(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
Some(u) => u,
None => return Ok(Redirect::to("/").into_response()),
};
let rows = list_for_user(&state.pool, user_id, 100)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to load audit log for user");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entries = rows
.into_iter()
.map(|row| AuditEntryView {
created_at_iso: row.created_at.to_rfc3339_opts(SecondsFormat::Secs, true),
action: row.action,
target: format_audit_target(&row.namespace, &row.kind, &row.name),
detail: serde_json::to_string_pretty(&row.detail).unwrap_or_else(|_| "{}".to_string()),
})
.collect();
let tmpl = AuditPageTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
entries,
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── Account bind/unbind ───────────────────────────────────────────────────────
async fn account_bind_google(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let _ = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
session
.insert(SESSION_OAUTH_BIND_MODE, true)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let redirect_uri = format!("{}/account/bind/google/callback", state.base_url);
let mut cfg = state
.google_config
.clone()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
cfg.redirect_uri = redirect_uri;
let st = random_state();
session.insert(SESSION_OAUTH_STATE, &st).await.ok();
Ok(Redirect::to(&google_auth_url(&cfg, &st)).into_response())
}
async fn account_bind_google_callback(
State(state): State<AppState>,
connect_info: ConnectInfo<SocketAddr>,
headers: HeaderMap,
session: Session,
Query(params): Query<OAuthCallbackQuery>,
) -> Result<Response, StatusCode> {
let client_ip = request_client_ip(&headers, connect_info);
let user_agent = request_user_agent(&headers);
handle_oauth_callback(
&state,
&session,
params,
"google",
client_ip.as_deref(),
user_agent.as_deref(),
|s, cfg, code| {
Box::pin(crate::oauth::google::exchange_code(
&s.http_client,
cfg,
code,
))
},
)
.await
}
async fn account_unbind(
State(state): State<AppState>,
Path(provider): Path<String>,
session: Session,
) -> Result<Response, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let current_login_provider = session
.get::<String>(SESSION_LOGIN_PROVIDER)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
unbind_oauth_account(
&state.pool,
user_id,
&provider,
current_login_provider.as_deref(),
)
.await
.map_err(|e| {
tracing::warn!(error = %e, "failed to unbind oauth account");
StatusCode::BAD_REQUEST
})?;
Ok(Redirect::to("/dashboard?unbound=1").into_response())
}
// ── Passphrase / Key setup API ────────────────────────────────────────────────
#[derive(Serialize)]
struct KeySaltResponse {
has_passphrase: bool,
#[serde(skip_serializing_if = "Option::is_none")]
salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
key_check: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
params: Option<serde_json::Value>,
}
async fn api_key_salt(
State(state): State<AppState>,
session: Session,
) -> Result<Json<KeySaltResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
if user.key_salt.is_none() {
return Ok(Json(KeySaltResponse {
has_passphrase: false,
salt: None,
key_check: None,
params: None,
}));
}
Ok(Json(KeySaltResponse {
has_passphrase: true,
salt: user.key_salt.as_deref().map(hex::encode_hex),
key_check: user.key_check.as_deref().map(hex::encode_hex),
params: user.key_params,
}))
}
#[derive(Deserialize)]
struct KeySetupRequest {
/// Hex-encoded 32-byte random salt
salt: String,
/// Hex-encoded AES-256-GCM encryption of "secrets-mcp-key-check" with the derived key
key_check: String,
/// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}
params: serde_json::Value,
}
#[derive(Serialize)]
struct KeySetupResponse {
ok: bool,
}
async fn api_key_setup(
State(state): State<AppState>,
session: Session,
Json(body): Json<KeySetupRequest>,
) -> Result<Json<KeySetupResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let salt = hex::decode_hex(&body.salt).map_err(|_| StatusCode::BAD_REQUEST)?;
let key_check = hex::decode_hex(&body.key_check).map_err(|_| StatusCode::BAD_REQUEST)?;
if salt.len() != 32 {
return Err(StatusCode::BAD_REQUEST);
}
update_user_key_setup(&state.pool, user_id, &salt, &key_check, &body.params)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to update key setup");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(KeySetupResponse { ok: true }))
}
// ── API Key management ────────────────────────────────────────────────────────
#[derive(Serialize)]
struct ApiKeyResponse {
api_key: String,
}
async fn api_apikey_get(
State(state): State<AppState>,
session: Session,
) -> Result<Json<ApiKeyResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let api_key = ensure_api_key(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ApiKeyResponse { api_key }))
}
async fn api_apikey_regenerate(
State(state): State<AppState>,
session: Session,
) -> Result<Json<ApiKeyResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let api_key = regenerate_api_key(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ApiKeyResponse { api_key }))
}
// ── Helper ────────────────────────────────────────────────────────────────────
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
let html = tmpl.render().map_err(|e| {
tracing::error!(error = %e, "template render error");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(html).into_response())
}
fn format_audit_target(namespace: &str, kind: &str, name: &str) -> String {
// Auth events reuse kind/name as a provider-scoped target, not an entry identity.
if namespace == "auth" {
format!("{}/{}", kind, name)
} else {
format!("[{}/{}] {}", namespace, kind, name)
}
}

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<text x="16" y="23" text-anchor="middle" font-family="system-ui,Segoe UI,sans-serif" font-size="22" font-weight="700" fill="#58a6ff">S</text>
</svg>

Before

Width:  |  Height:  |  Size: 212 B

View File

@@ -1,154 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — Audit</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #58a6ff; --accent-hover: #79b8ff;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.btn-sign-out {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { background: var(--surface2); }
.main { padding: 32px 24px 40px; flex: 1; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
th { color: var(--text-muted); font-size: 12px; font-weight: 600; }
td { font-size: 13px; }
.mono { font-family: 'JetBrains Mono', monospace; }
.detail {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
max-width: 460px;
}
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; }
.sidebar-link { flex: 1; text-align: center; }
.main { padding: 20px 12px 28px; }
.card { padding: 16px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { border-top: 1px solid var(--border); padding: 12px 0; }
td { border-top: none; padding: 6px 0; }
td::before {
display: block; color: var(--text-muted); font-size: 11px;
margin-bottom: 4px; text-transform: uppercase;
}
td.col-time::before { content: "Time"; }
td.col-action::before { content: "Action"; }
td.col-target::before { content: "Target"; }
td.col-detail::before { content: "Detail"; }
.detail { max-width: none; }
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link">MCP</a>
<a href="/audit" class="sidebar-link active">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out">退出</button>
</form>
</div>
<main class="main">
<section class="card">
<div class="card-title">我的审计</div>
<div class="card-subtitle">展示最近 100 条与当前用户相关的新审计记录。时间为浏览器本地时区。</div>
{% if entries.is_empty() %}
<div class="empty">暂无审计记录。</div>
{% else %}
<table>
<thead>
<tr>
<th>时间</th>
<th>动作</th>
<th>目标</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td class="col-time mono"><time class="audit-local-time" datetime="{{ entry.created_at_iso }}">{{ entry.created_at_iso }}</time></td>
<td class="col-action mono">{{ entry.action }}</td>
<td class="col-target mono">{{ entry.target }}</td>
<td class="col-detail"><pre class="detail">{{ entry.detail }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
</main>
</div>
</div>
<script>
(function () {
document.querySelectorAll('time.audit-local-time[datetime]').forEach(function (el) {
var raw = el.getAttribute('datetime');
var d = raw ? new Date(raw) : null;
if (d && !isNaN(d.getTime())) {
el.textContent = d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
el.title = raw + ' (UTC)';
}
});
})();
</script>
</body>
</html>

View File

@@ -1,939 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #58a6ff; --accent-hover: #79b8ff;
--danger: #f85149; --success: #3fb950; --warn: #d29922;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); }
.btn-sign-out { padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; cursor: pointer; }
.btn-sign-out:hover { background: var(--surface2); }
/* Main content column */
.main { display: flex; flex-direction: column; align-items: center;
padding: 24px 20px 8px; min-height: 0; }
.app-footer {
margin-top: auto;
text-align: center;
padding: 4px 20px 12px;
font-size: 12px;
color: #9da7b3;
font-family: 'JetBrains Mono', monospace;
}
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 980px; }
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
/* Form */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
.field input { width: 100%; background: var(--bg); border: 1px solid var(--border);
color: var(--text); padding: 9px 12px; border-radius: 6px;
font-size: 13px; outline: none; }
.field input:focus { border-color: var(--accent); }
.pw-field { position: relative; }
.pw-field > input { padding-right: 42px; }
.pw-toggle {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border: none; border-radius: 6px;
background: transparent; color: var(--text-muted); cursor: pointer;
}
.pw-toggle:hover { color: var(--text); background: var(--surface2); }
.pw-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pw-icon svg { display: block; }
.pw-icon.hidden { display: none; }
.error-msg { color: var(--danger); font-size: 12px; margin-top: 6px; display: none; }
/* Buttons */
.btn-primary { display: inline-flex; align-items: center; gap: 6px; width: 100%;
justify-content: center; padding: 10px 20px; border-radius: 7px;
border: none; background: var(--accent); color: #0d1117;
font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px;
border-radius: 5px; border: 1px solid var(--border); background: none;
color: var(--text-muted); font-size: 12px; cursor: pointer; }
.btn-sm:hover { color: var(--text); border-color: var(--text-muted); }
.btn-copy { display: flex; align-items: center; gap: 8px; width: 100%; justify-content: center;
padding: 11px 20px; border-radius: 7px; border: 1px solid var(--success);
background: rgba(63,185,80,0.1); color: var(--success);
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
.btn-copy:hover { background: rgba(63,185,80,0.2); }
.btn-copy.copied { background: var(--success); color: #0d1117; border-color: var(--success); }
/* Config format switcher */
.config-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text-muted); cursor: pointer;
font-family: inherit; text-align: left; transition: border-color 0.15s, background 0.15s, transform 0.15s; }
.config-tab:hover { color: var(--text); border-color: var(--accent); transform: translateY(-1px); }
.config-tab.active { background: rgba(88,166,255,0.1); color: var(--text); border-color: var(--accent); }
.config-tab-title { display: block; font-size: 13px; font-weight: 600; color: inherit; }
/* Config box */
.config-wrap { position: relative; margin-bottom: 14px; }
.config-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 16px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
line-height: 1.7; color: var(--text); overflow-x: auto; white-space: pre; }
.config-box.locked { color: var(--text-muted); filter: blur(3px); user-select: none;
pointer-events: none; }
.config-key { color: #79c0ff; }
.config-str { color: #a5d6ff; }
.config-val { color: var(--accent); }
/* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
/* Actions row */
.actions-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
/* Spinner */
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(13,17,23,0.3);
border-top-color: #0d1117; border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Modal */
.modal-bd { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75);
z-index: 100; align-items: center; justify-content: center; }
.modal-bd.open { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 28px; width: 100%; max-width: 420px; }
.modal h3 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 8px; margin-top: 16px; }
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 6px; border: none;
background: var(--accent); color: #0d1117; font-size: 13px;
font-weight: 600; cursor: pointer; }
.btn-modal-ok:hover { background: var(--accent-hover); }
.btn-modal-cancel { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 13px; cursor: pointer; }
.btn-modal-cancel:hover { background: var(--surface2); }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; }
.sidebar-link { flex: 1; text-align: center; }
}
@media (max-width: 720px) {
.config-tabs { grid-template-columns: 1fr; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
.main { padding: 16px 12px 6px; }
.app-footer { padding: 4px 12px 10px; }
.card { padding: 18px; }
}
</style>
</head>
<body data-has-passphrase="{{ has_passphrase }}" data-base-url="{{ base_url }}">
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link active">MCP</a>
<a href="/audit" class="sidebar-link">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<div class="lang-bar">
<button class="lang-btn" onclick="setLang('zh-CN')"></button>
<button class="lang-btn" onclick="setLang('zh-TW')"></button>
<button class="lang-btn" onclick="setLang('en')">EN</button>
</div>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button>
</form>
</div>
<div class="main">
<div class="card">
<!-- ── Locked state ──────────────────────────────────────────────────── -->
<div id="locked-view">
<div class="card-title" data-i18n="lockedTitle">获取 MCP 配置</div>
<!-- placeholder config -->
<div class="config-wrap">
<div class="config-box locked" id="placeholder-config"></div>
</div>
<!-- Setup form (no passphrase yet) -->
<div id="setup-form" style="display:none">
<div class="field">
<label data-i18n="labelPassphrase">加密密码</label>
<div class="pw-field">
<input type="password" id="setup-pass1" data-i18n-ph="phPassphrase" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="setup-pass1" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="field">
<label data-i18n="labelConfirm">确认密码</label>
<div class="pw-field">
<input type="password" id="setup-pass2" data-i18n-ph="phConfirm" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="setup-pass2" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="error-msg" id="setup-error"></div>
<button class="btn-primary" id="setup-btn" onclick="doSetup()">
<span data-i18n="btnSetup">设置并获取配置</span>
</button>
<p style="font-size:11px;color:var(--text-muted);text-align:center;margin-top:10px" data-i18n="setupNote">
密码不会上传服务器。遗忘后数据将无法恢复。
</p>
</div>
<!-- Unlock form (passphrase already set) -->
<div id="unlock-form" style="display:none">
<div class="field">
<label data-i18n="labelPassphrase">加密密码</label>
<div class="pw-field">
<input type="password" id="unlock-pass" data-i18n-ph="phPassphrase" autocomplete="current-password">
<button type="button" class="pw-toggle" data-target="unlock-pass" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="error-msg" id="unlock-error"></div>
<button class="btn-primary" id="unlock-btn" onclick="doUnlock()">
<span data-i18n="btnUnlock">解锁并获取配置</span>
</button>
</div>
</div>
<!-- ── Unlocked state ────────────────────────────────────────────────── -->
<div id="unlocked-view" style="display:none">
<div class="card-title" data-i18n="unlockedTitle" style="margin-bottom:16px">MCP 配置</div>
<div class="config-tabs" role="tablist" aria-label="Config format">
<button type="button" class="config-tab active" role="tab" id="tab-mcp" aria-selected="true"
onclick="setConfigFormat('mcp')">
<span class="config-tab-title" data-i18n="tabMcp">Cursor、Claude Code、Codex、Gemini CLI</span>
</button>
<button type="button" class="config-tab" role="tab" id="tab-opencode" aria-selected="false"
onclick="setConfigFormat('opencode')">
<span class="config-tab-title" data-i18n="tabOpencode">OpenCode</span>
</button>
</div>
<div class="config-wrap">
<pre class="config-box" id="real-config"></pre>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<button class="btn-copy" id="copy-full-btn" onclick="copyFullConfig()" style="flex:1">
<span id="copy-full-text">复制完整 mcp.json</span>
</button>
<button class="btn-copy" id="copy-secrets-btn" onclick="copySecretsConfig()" style="flex:1">
<span id="copy-secrets-text">仅复制 secrets 节点</span>
</button>
</div>
<hr class="divider">
<div class="actions-row">
<button class="btn-sm" onclick="clearAndLock()" data-i18n="btnClear">清除密钥</button>
<button class="btn-sm" onclick="openChangeModal()" data-i18n="btnChangePass">更换密码</button>
<button class="btn-sm" onclick="confirmRegenerate()" data-i18n="btnRegen">重置 API Key</button>
</div>
</div>
</div>
</div>
<footer class="app-footer">{{ version }}</footer>
</div>
</div>
</div>
<!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
<div class="modal-bd" id="change-modal">
<div class="modal">
<h3 data-i18n="changeTitle">更换密码</h3>
<div class="field">
<label data-i18n="labelNew">新密码</label>
<div class="pw-field">
<input type="password" id="change-pass1" data-i18n-ph="phPassphrase" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="change-pass1" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="field">
<label data-i18n="labelConfirm">确认</label>
<div class="pw-field">
<input type="password" id="change-pass2" data-i18n-ph="phConfirm" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="change-pass2" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="error-msg" id="change-error"></div>
<div class="modal-actions">
<button class="btn-modal-ok" id="change-btn" onclick="doChange()" data-i18n="btnChange">确认更换</button>
<button class="btn-modal-cancel" onclick="closeChangeModal()" data-i18n="btnCancel">取消</button>
</div>
</div>
</div>
<script>
// ── i18n ───────────────────────────────────────────────────────────────────────
const T = {
'zh-CN': {
signOut: '退出',
lockedTitle: '获取 MCP 配置',
labelPassphrase: '加密密码',
labelConfirm: '确认密码',
labelNew: '新密码',
phPassphrase: '输入密码…',
phConfirm: '再次输入…',
btnSetup: '设置并获取配置',
btnUnlock: '解锁并获取配置',
setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。',
errEmpty: '密码不能为空。',
errShort: '密码至少需要 8 个字符。',
errMismatch: '两次输入不一致。',
errWrong: '密码错误,请重试。',
unlockedTitle: 'MCP 配置',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode',
btnCopyFull: '复制完整 mcp.json',
btnCopySecrets: '仅复制 secrets 节点',
btnCopyFullOpencode: '复制完整 mcp.json',
btnCopySecretsOpencode: '仅复制 secrets 节点',
btnCopied: '已复制!',
btnClear: '清除密钥',
btnChangePass: '更换密码',
btnRegen: '重置 API Key',
changeTitle: '更换密码',
btnChange: '确认更换',
btnCancel: '取消',
regenConfirm: '重置 API Key 后,当前 Key 立即失效,需同步更新 AI 客户端配置。确认继续?',
regenFailed: '重置失败,请刷新页面重试。',
ariaShowPw: '显示密码',
ariaHidePw: '隐藏密码',
},
'zh-TW': {
signOut: '登出',
lockedTitle: '取得 MCP 設定',
labelPassphrase: '加密密碼',
labelConfirm: '確認密碼',
labelNew: '新密碼',
phPassphrase: '輸入密碼…',
phConfirm: '再次輸入…',
btnSetup: '設定並取得設定',
btnUnlock: '解鎖並取得設定',
setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。',
errEmpty: '密碼不能為空。',
errShort: '密碼至少需要 8 個字元。',
errMismatch: '兩次輸入不一致。',
errWrong: '密碼錯誤,請重試。',
unlockedTitle: 'MCP 設定',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode',
btnCopyFull: '複製完整 mcp.json',
btnCopySecrets: '僅複製 secrets 節點',
btnCopyFullOpencode: '複製完整 mcp.json',
btnCopySecretsOpencode: '僅複製 secrets 節點',
btnCopied: '已複製!',
btnClear: '清除密鑰',
btnChangePass: '更換密碼',
btnRegen: '重置 API Key',
changeTitle: '更換密碼',
btnChange: '確認更換',
btnCancel: '取消',
regenConfirm: '重置 API Key 後,目前 Key 立即失效,需同步更新 AI 用戶端設定。確認繼續?',
regenFailed: '重置失敗,請重新整理頁面再試。',
ariaShowPw: '顯示密碼',
ariaHidePw: '隱藏密碼',
},
'en': {
signOut: 'Sign out',
lockedTitle: 'Get MCP Config',
labelPassphrase: 'Encryption password',
labelConfirm: 'Confirm password',
labelNew: 'New password',
phPassphrase: 'Enter password…',
phConfirm: 'Repeat password…',
btnSetup: 'Set up & get config',
btnUnlock: 'Unlock & get config',
setupNote: 'Your password never leaves this device. If forgotten, encrypted data cannot be recovered.',
errEmpty: 'Password cannot be empty.',
errShort: 'Password must be at least 8 characters.',
errMismatch: 'Passwords do not match.',
errWrong: 'Incorrect password, please try again.',
unlockedTitle: 'MCP Config',
tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI',
tabOpencode: 'OpenCode',
btnCopyFull: 'Copy full mcp.json',
btnCopySecrets: 'Copy only secrets node',
btnCopyFullOpencode: 'Copy full mcp.json',
btnCopySecretsOpencode: 'Copy only secrets node',
btnCopied: 'Copied!',
btnClear: 'Clear key',
btnChangePass: 'Change password',
btnRegen: 'Reset API key',
changeTitle: 'Change password',
btnChange: 'Confirm',
btnCancel: 'Cancel',
regenConfirm: 'Resetting will immediately invalidate your current API key. You will need to update your AI client config. Continue?',
regenFailed: 'Reset failed. Please refresh and try again.',
ariaShowPw: 'Show password',
ariaHidePw: 'Hide password',
}
};
let currentLang = localStorage.getItem('lang') || 'zh-CN';
function t(key) { return T[currentLang][key] || T['en'][key] || key; }
function applyLang() {
document.documentElement.lang = currentLang;
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
el.placeholder = t(el.getAttribute('data-i18n-ph'));
});
document.querySelectorAll('.lang-btn').forEach(btn => {
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);
});
// Rebuild placeholder config (language affects nothing but triggers re-render)
renderPlaceholderConfig();
// Rebuild real config if unlocked
if (currentEncKey && currentApiKey) renderRealConfig();
syncPwToggleI18n();
syncConfigFormatUi();
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
applyLang();
}
function syncPwToggleI18n() {
document.querySelectorAll('.pw-toggle').forEach(btn => {
const input = document.getElementById(btn.getAttribute('data-target'));
if (!input) return;
const visible = input.type === 'text';
btn.setAttribute('aria-pressed', visible ? 'true' : 'false');
btn.setAttribute('aria-label', visible ? t('ariaHidePw') : t('ariaShowPw'));
const showIc = btn.querySelector('.pw-icon-show');
const hideIc = btn.querySelector('.pw-icon-hide');
if (showIc) showIc.classList.toggle('hidden', visible);
if (hideIc) hideIc.classList.toggle('hidden', !visible);
});
}
function togglePwVisibility(btn) {
const input = document.getElementById(btn.getAttribute('data-target'));
if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password';
syncPwToggleI18n();
}
// ── Constants ──────────────────────────────────────────────────────────────────
const HAS_PASSPHRASE = document.body.dataset.hasPassphrase === 'true';
const BASE_URL = document.body.dataset.baseUrl;
const KEY_CHECK_PLAINTEXT = 'secrets-mcp-key-check';
const PBKDF2_ITERATIONS = 600000;
const ENC = new TextEncoder();
let currentEncKey = null;
let currentApiKey = null;
/** @type {'mcp' | 'opencode'} */
let configFormat = 'mcp';
function redirectLoginExpired() {
sessionStorage.removeItem('enc_key');
currentEncKey = null;
currentApiKey = null;
window.location.replace('/');
}
/** Like fetch; on 401 clears local state and navigates to login (await does not complete). */
async function fetchAuth(input, init) {
const resp = await fetch(input, init);
if (resp.status === 401) {
redirectLoginExpired();
await new Promise(() => {});
}
return resp;
}
// ── Placeholder config ─────────────────────────────────────────────────────────
function renderPlaceholderConfig() {
document.getElementById('placeholder-config').textContent =
buildConfigText('sk_' + '•'.repeat(64), '•'.repeat(64));
}
function buildBaseServerConfig(apiKey, encKey) {
return {
url: BASE_URL + '/mcp',
headers: {
Authorization: 'Bearer ' + apiKey,
'X-Encryption-Key': encKey
}
};
}
function buildSecretsEntryObject(apiKey, encKey) {
return buildBaseServerConfig(apiKey, encKey);
}
function buildConfigText(apiKey, encKey) {
return JSON.stringify({ mcpServers: { secrets: buildSecretsEntryObject(apiKey, encKey) } }, null, 2);
}
function buildSecretsConfigText(apiKey, encKey) {
const wrapped = JSON.stringify({
secrets: buildSecretsEntryObject(apiKey, encKey)
}, null, 2);
const lines = wrapped.split('\n');
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
}
/** OpenCode: local stdio bridge to Streamable HTTP MCP (mcp-remote --transport http-only). */
function buildOpencodeEntry(apiKey, encKey) {
return {
type: 'local',
command: [
'npx', '-y', 'mcp-remote',
BASE_URL + '/mcp',
'--header',
'Authorization: Bearer ' + apiKey,
'--header',
'X-Encryption-Key: ' + encKey,
'--transport',
'http-only'
]
};
}
/** Full OpenCode config: MCP servers live under top-level `mcp`. */
function buildOpencodeConfigText(apiKey, encKey) {
return JSON.stringify({ mcp: { secrets: buildOpencodeEntry(apiKey, encKey) } }, null, 2);
}
/** Strip outer `{` `}` so user can paste `secrets` under an existing `mcp` object. */
function buildOpencodeMergeSnippet(apiKey, encKey) {
const wrapped = JSON.stringify({ secrets: buildOpencodeEntry(apiKey, encKey) }, null, 2);
const lines = wrapped.split('\n');
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
}
function getCopyFullKey() {
return 'btnCopyFull';
}
function getCopySecretsKey() {
return 'btnCopySecrets';
}
const CONFIG_FORMAT_STORAGE = 'dash_config_format';
function setConfigFormat(fmt) {
configFormat = fmt;
try { sessionStorage.setItem(CONFIG_FORMAT_STORAGE, fmt); } catch (_) {}
syncConfigFormatUi();
if (currentEncKey && currentApiKey) renderRealConfig();
}
/** Refresh tabs, format hint, and copy button labels (after language change or tab switch). */
function syncConfigFormatUi() {
const uv = document.getElementById('unlocked-view');
if (!uv || uv.style.display === 'none') return;
const tabMcp = document.getElementById('tab-mcp');
const tabOc = document.getElementById('tab-opencode');
if (tabMcp && tabOc) {
tabMcp.classList.toggle('active', configFormat === 'mcp');
tabOc.classList.toggle('active', configFormat === 'opencode');
tabMcp.setAttribute('aria-selected', configFormat === 'mcp' ? 'true' : 'false');
tabOc.setAttribute('aria-selected', configFormat === 'opencode' ? 'true' : 'false');
}
const cf = document.getElementById('copy-full-text');
const cs = document.getElementById('copy-secrets-text');
if (cf) cf.textContent = t(getCopyFullKey());
if (cs) cs.textContent = t(getCopySecretsKey());
}
// ── Unlock / Setup flow ───────────────────────────────────────────────────────
function showLockedView() {
document.getElementById('locked-view').style.display = '';
document.getElementById('unlocked-view').style.display = 'none';
if (HAS_PASSPHRASE) {
document.getElementById('setup-form').style.display = 'none';
document.getElementById('unlock-form').style.display = '';
setTimeout(() => document.getElementById('unlock-pass').focus(), 50);
} else {
document.getElementById('setup-form').style.display = '';
document.getElementById('unlock-form').style.display = 'none';
setTimeout(() => document.getElementById('setup-pass1').focus(), 50);
}
}
async function showUnlockedView(encKeyHex, apiKey) {
currentEncKey = encKeyHex;
currentApiKey = apiKey;
sessionStorage.setItem('enc_key', encKeyHex);
renderRealConfig();
document.getElementById('locked-view').style.display = 'none';
document.getElementById('unlocked-view').style.display = '';
syncConfigFormatUi();
}
function renderRealConfig() {
if (!currentApiKey || !currentEncKey) return;
const text = configFormat === 'mcp'
? buildConfigText(currentApiKey, currentEncKey)
: buildOpencodeConfigText(currentApiKey, currentEncKey);
document.getElementById('real-config').textContent = text;
}
function clearAndLock() {
sessionStorage.removeItem('enc_key');
currentEncKey = null;
currentApiKey = null;
showLockedView();
}
// ── Web Crypto helpers ─────────────────────────────────────────────────────────
async function deriveKey(passphrase, saltBytes, extractable = false) {
const km = await crypto.subtle.importKey('raw', ENC.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
km, { name: 'AES-GCM', length: 256 }, extractable, ['encrypt', 'decrypt']
);
}
async function exportKeyHex(cryptoKey) {
const raw = await crypto.subtle.exportKey('raw', cryptoKey);
return Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2, '0')).join('');
}
function hexToBytes(hex) {
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) b[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return b;
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function encryptKeyCheck(cryptoKey) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, cryptoKey, ENC.encode(KEY_CHECK_PLAINTEXT));
const out = new Uint8Array(12 + ct.byteLength);
out.set(nonce); out.set(new Uint8Array(ct), 12);
return bytesToHex(out);
}
async function verifyKeyCheck(cryptoKey, keyCheckHex) {
try {
const b = hexToBytes(keyCheckHex);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.slice(0, 12) }, cryptoKey, b.slice(12));
return new TextDecoder().decode(plain) === KEY_CHECK_PLAINTEXT;
} catch { return false; }
}
// ── Passphrase setup (first time) ─────────────────────────────────────────────
function setBtnLoading(id, loading, labelKey) {
const btn = document.getElementById(id);
btn.disabled = loading;
btn.innerHTML = loading
? '<span class="spinner"></span>'
: `<span data-i18n="${labelKey}">${t(labelKey)}</span>`;
}
async function doSetup() {
const pass1 = document.getElementById('setup-pass1').value;
const pass2 = document.getElementById('setup-pass2').value;
const errEl = document.getElementById('setup-error');
errEl.style.display = 'none';
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
setBtnLoading('setup-btn', true, 'btnSetup');
try {
const salt = crypto.getRandomValues(new Uint8Array(32));
const cryptoKey = await deriveKey(pass1, salt, true);
const keyCheckHex = await encryptKeyCheck(cryptoKey);
const hexKey = await exportKeyHex(cryptoKey);
const resp = await fetchAuth('/api/key-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
salt: bytesToHex(salt),
key_check: keyCheckHex,
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
})
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const apiKey = await fetchApiKey();
await showUnlockedView(hexKey, apiKey);
} catch (e) {
showErr(errEl, 'Error: ' + e.message);
} finally {
setBtnLoading('setup-btn', false, 'btnSetup');
}
}
// ── Passphrase unlock ──────────────────────────────────────────────────────────
async function doUnlock() {
const pass = document.getElementById('unlock-pass').value;
const errEl = document.getElementById('unlock-error');
errEl.style.display = 'none';
if (!pass) { showErr(errEl, t('errEmpty')); return; }
setBtnLoading('unlock-btn', true, 'btnUnlock');
try {
const saltResp = await fetchAuth('/api/key-salt');
if (!saltResp.ok) throw new Error('HTTP ' + saltResp.status);
const saltData = await saltResp.json();
const cryptoKey = await deriveKey(pass, hexToBytes(saltData.salt), true);
const valid = await verifyKeyCheck(cryptoKey, saltData.key_check);
if (!valid) { showErr(errEl, t('errWrong')); return; }
const hexKey = await exportKeyHex(cryptoKey);
const apiKey = await fetchApiKey();
await showUnlockedView(hexKey, apiKey);
} catch (e) {
showErr(errEl, 'Error: ' + e.message);
} finally {
setBtnLoading('unlock-btn', false, 'btnUnlock');
}
}
// ── Copy config ────────────────────────────────────────────────────────────────
async function copyFullConfig() {
await copyWithFeedback(
document.getElementById('real-config').textContent,
'copy-full-btn',
'copy-full-text',
getCopyFullKey()
);
}
async function copySecretsConfig() {
const snippet = configFormat === 'mcp'
? buildSecretsConfigText(currentApiKey, currentEncKey)
: buildOpencodeMergeSnippet(currentApiKey, currentEncKey);
await copyWithFeedback(
snippet,
'copy-secrets-btn',
'copy-secrets-text',
getCopySecretsKey()
);
}
async function copyWithFeedback(text, btnId, textId, resetLabelKey) {
await navigator.clipboard.writeText(text);
const btn = document.getElementById(btnId);
const textEl = document.getElementById(textId);
btn.classList.add('copied');
textEl.textContent = t('btnCopied');
setTimeout(() => {
btn.classList.remove('copied');
textEl.textContent = t(resetLabelKey);
}, 2500);
}
// ── Reset API key ──────────────────────────────────────────────────────────────
async function confirmRegenerate() {
if (!confirm(t('regenConfirm'))) return;
try {
const resp = await fetchAuth('/api/apikey/regenerate', { method: 'POST' });
if (!resp.ok) throw new Error();
const data = await resp.json();
currentApiKey = data.api_key;
renderRealConfig();
} catch {
alert(t('regenFailed'));
}
}
// ── Change passphrase modal ────────────────────────────────────────────────────
function openChangeModal() {
document.getElementById('change-pass1').value = '';
document.getElementById('change-pass2').value = '';
document.getElementById('change-pass1').type = 'password';
document.getElementById('change-pass2').type = 'password';
document.getElementById('change-error').style.display = 'none';
document.getElementById('change-modal').classList.add('open');
syncPwToggleI18n();
setTimeout(() => document.getElementById('change-pass1').focus(), 50);
}
function closeChangeModal() {
document.getElementById('change-modal').classList.remove('open');
}
async function doChange() {
const pass1 = document.getElementById('change-pass1').value;
const pass2 = document.getElementById('change-pass2').value;
const errEl = document.getElementById('change-error');
errEl.style.display = 'none';
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
const btn = document.getElementById('change-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>';
try {
const salt = crypto.getRandomValues(new Uint8Array(32));
const cryptoKey = await deriveKey(pass1, salt, true);
const keyCheckHex = await encryptKeyCheck(cryptoKey);
const hexKey = await exportKeyHex(cryptoKey);
const resp = await fetchAuth('/api/key-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
salt: bytesToHex(salt),
key_check: keyCheckHex,
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
})
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
currentEncKey = hexKey;
sessionStorage.setItem('enc_key', hexKey);
renderRealConfig();
closeChangeModal();
} catch (e) {
showErr(errEl, 'Error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = t('btnChange');
}
}
// ── Fetch API key ──────────────────────────────────────────────────────────────
async function fetchApiKey() {
const resp = await fetchAuth('/api/apikey');
if (!resp.ok) throw new Error('Failed to load API key');
const data = await resp.json();
return data.api_key;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function showErr(el, msg) {
el.textContent = msg;
el.style.display = 'block';
}
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeChangeModal();
if (e.key === 'Enter') {
if (document.getElementById('change-modal').classList.contains('open')) { doChange(); return; }
if (document.getElementById('unlock-form').style.display !== 'none' &&
document.getElementById('locked-view').style.display !== 'none') { doUnlock(); return; }
if (document.getElementById('setup-form').style.display !== 'none' &&
document.getElementById('locked-view').style.display !== 'none') { doSetup(); return; }
}
});
// ── Init ───────────────────────────────────────────────────────────────────────
(async function init() {
applyLang();
try {
const sf = sessionStorage.getItem(CONFIG_FORMAT_STORAGE);
if (sf === 'mcp' || sf === 'opencode') configFormat = sf;
} catch (_) { /* ignore */ }
const savedKey = sessionStorage.getItem('enc_key');
if (savedKey) {
try {
const apiKey = await fetchApiKey();
await showUnlockedView(savedKey, apiKey);
return;
} catch { /* fall through to locked */ }
}
showLockedView();
})();
</script>
</body>
</html>

View File

@@ -1,125 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-hover: #79b8ff;
--google: #4285f4;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif;
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.card {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 48px 40px; width: 100%; max-width: 400px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.topbar { display: flex; justify-content: flex-end; margin-bottom: 20px; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); }
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 32px; }
.btn {
display: flex; align-items: center; justify-content: center; gap: 12px;
width: 100%; padding: 12px 20px; border: 1px solid var(--border); border-radius: 8px;
background: var(--surface); color: var(--text); font-size: 14px; font-weight: 500;
cursor: pointer; text-decoration: none; transition: all 0.2s;
}
.btn:hover { background: var(--border); border-color: var(--text-muted); }
.btn + .btn { margin-top: 12px; }
.btn svg { flex-shrink: 0; }
.footer { margin-top: 28px; text-align: center; color: var(--text-muted); font-size: 12px; }
.footer a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<div class="topbar">
<div class="lang-bar">
<button class="lang-btn" onclick="setLang('zh-CN')"></button>
<button class="lang-btn" onclick="setLang('zh-TW')"></button>
<button class="lang-btn" onclick="setLang('en')">EN</button>
</div>
</div>
<h1 data-i18n="title">登录</h1>
<p class="subtitle" data-i18n="subtitle">安全管理你的跨设备 secrets。</p>
{% if has_google %}
<a href="/auth/google" class="btn">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 01-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 00.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
<span data-i18n="google">使用 Google 登录</span>
</a>
{% endif %}
{% if !has_google %}
<p style="text-align:center; color: var(--text-muted); font-size: 14px;" data-i18n="noProviders">
未配置登录方式,请联系管理员。
</p>
{% endif %}
</div>
<script>
const T = {
'zh-CN': {
title: '登录',
subtitle: '安全管理你的跨设备 secrets。',
google: '使用 Google 登录',
noProviders: '未配置登录方式,请联系管理员。',
},
'zh-TW': {
title: '登入',
subtitle: '安全管理你的跨裝置 secrets。',
google: '使用 Google 登入',
noProviders: '尚未設定登入方式,請聯絡管理員。',
},
'en': {
title: 'Sign in',
subtitle: 'Manage your cross-device secrets securely.',
google: 'Continue with Google',
noProviders: 'No login providers configured. Please contact your administrator.',
}
};
let currentLang = localStorage.getItem('lang') || 'zh-CN';
function t(key) { return T[currentLang][key] || T['en'][key] || key; }
function applyLang() {
document.documentElement.lang = currentLang;
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('.lang-btn').forEach(btn => {
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);
});
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
applyLang();
}
applyLang();
</script>
</body>
</html>

View File

@@ -1,27 +1,57 @@
# Secrets MCP Server 环境变量配置
# Secrets v3 环境变量配置
# 复制此文件为 .env 并填写真实值
# ─── 数据库 ───────────────────────────────────────────────────────────
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@HOST:PORT/secrets-mcp
# v3 API 与桌面端都复用这套数据库
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@db.refining.ltd:5432/secrets-v3
# 强烈建议生产使用 verify-full至少 verify-ca
SECRETS_DATABASE_SSL_MODE=verify-full
# 私有 CA 或自建链路时填写 CA 根证书路径;使用公共受信 CA 可留空
# SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
# 当设为 prod/production 时,服务会拒绝弱 TLS 模式prefer/disable/allow/require
SECRETS_ENV=production
# ─── 服务地址 ─────────────────────────────────────────────────────────
# 内网监听地址Cloudflare / Nginx 反代时填内网端口)
SECRETS_MCP_BIND=127.0.0.1:9315
SECRETS_API_BIND=127.0.0.1:9415
SECRETS_DAEMON_BIND=127.0.0.1:9515
SECRETS_API_BASE=http://127.0.0.1:9415
SECRETS_DAEMON_URL=http://127.0.0.1:9515/mcp
# 对外 HTTPS 地址(用于 OAuth 回调 URL 拼接)
BASE_URL=https://secrets.example.com
# ─── Google OAuth服务端托管──────────────────────────────────────────
# 官网 DMG 正式分发时Google OAuth 凭据只配置在 API 服务端
SECRETS_PUBLIC_BASE_URL=http://127.0.0.1:9415
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:9415/auth/google/callback
# 可选:如不配置则使用 Google 默认公开端点
# GOOGLE_OAUTH_AUTH_URI=https://accounts.google.com/o/oauth2/v2/auth
# GOOGLE_OAUTH_TOKEN_URI=https://oauth2.googleapis.com/token
# 若仍无法换 token仅提供端口代理、无系统代理可取消注释并改为本机代理地址
# HTTPS_PROXY=http://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1
# ─── Google OAuth ─────────────────────────────────────────────────────
# Google Cloud Console → APIs & Services → Credentials
# 授权回调 URI 须配置为:${BASE_URL}/auth/google/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# ─── 日志(可选)──────────────────────────────────────────────────────
# RUST_LOG=secrets_api=debug,secrets_desktop_daemon=debug
# ─── 微信登录(暂未开放,预留)───────────────────────────────────────
# WECHAT_APP_CLIENT_ID=
# WECHAT_APP_CLIENT_SECRET=
# ─── 数据库连接池(可选)──────────────────────────────────────────────
# 最大连接数,默认 10
# SECRETS_DATABASE_POOL_SIZE=10
# 获取连接超时秒数,默认 5
# SECRETS_DATABASE_ACQUIRE_TIMEOUT=5
# ─── 注意 ─────────────────────────────────────────────────────────────
# SERVER_MASTER_KEY 已不再需要。
# 新架构E2EE加密密钥由用户密码短语在客户端本地派生服务端不持有原始密钥。
# 仅在需要迁移旧版 wrapped_key 数据时临时启用。
# ─── 限流(可选)──────────────────────────────────────────────────────
# 全局限流速率req/s默认 100
# RATE_LIMIT_GLOBAL_PER_SECOND=100
# 全局限流突发量,默认 200
# RATE_LIMIT_GLOBAL_BURST=200
# 单 IP 限流速率req/s默认 20
# RATE_LIMIT_IP_PER_SECOND=20
# 单 IP 限流突发量,默认 40
# RATE_LIMIT_IP_BURST=40
# ─── 代理信任(可选)─────────────────────────────────────────────────
# 设为 1/true/yes 时从 X-Forwarded-For / X-Real-IP 提取客户端 IP
# 仅在反代环境下启用,否则客户端可伪造 IP 绕过限流
# TRUST_PROXY=1
# 桌面端会在 ~/.secrets-v3/desktop 下持久化 device token 与 device fingerprint

View File

@@ -0,0 +1,92 @@
# PostgreSQL TLS Hardening Runbook
This runbook applies to:
- PostgreSQL server: `47.117.131.22` (`db.refining.ltd`)
- `secrets-mcp` app server: `47.238.146.244` (`secrets.refining.app`)
## 1) Issue certificate for `db.refining.ltd` (Let's Encrypt + Cloudflare DNS-01)
Install `acme.sh` on the PostgreSQL server and use a Cloudflare API token with DNS edit permission for the target zone.
```bash
curl https://get.acme.sh | sh -s email=ops@refining.ltd
export CF_Token="your_cloudflare_dns_token"
export CF_Zone_ID="your_zone_id"
~/.acme.sh/acme.sh --issue --dns dns_cf -d db.refining.ltd --keylength ec-256
```
Install cert/key into a PostgreSQL-readable path:
```bash
sudo mkdir -p /etc/postgresql/tls
sudo ~/.acme.sh/acme.sh --install-cert -d db.refining.ltd --ecc \
--fullchain-file /etc/postgresql/tls/fullchain.pem \
--key-file /etc/postgresql/tls/privkey.pem \
--reloadcmd "systemctl reload postgresql || systemctl restart postgresql"
sudo chown -R postgres:postgres /etc/postgresql/tls
sudo chmod 600 /etc/postgresql/tls/privkey.pem
sudo chmod 644 /etc/postgresql/tls/fullchain.pem
```
## 2) Configure PostgreSQL TLS and access rules
In `postgresql.conf`:
```conf
ssl = on
ssl_cert_file = '/etc/postgresql/tls/fullchain.pem'
ssl_key_file = '/etc/postgresql/tls/privkey.pem'
```
In `pg_hba.conf`, allow app traffic via TLS only (example):
```conf
hostssl secrets-mcp postgres 47.238.146.244/32 scram-sha-256
```
Keep a safe admin path (`local` socket or restricted source CIDR) before removing old plaintext `host` rules.
Reload PostgreSQL:
```bash
sudo systemctl reload postgresql
```
## 3) Verify server-side TLS
```bash
openssl s_client -starttls postgres -connect db.refining.ltd:5432 -servername db.refining.ltd
```
The handshake should succeed and the certificate should match `db.refining.ltd`.
## 4) Update `secrets-mcp` app server env
Use environment values like:
```bash
SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp
SECRETS_DATABASE_SSL_MODE=verify-full
SECRETS_ENV=production
```
If you use private CA instead of public CA, also set:
```bash
SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
```
Restart `secrets-mcp` after updating env.
## 5) Verify from app server
Run positive and negative checks:
- Positive: app starts, migrations pass, dashboard + MCP API work.
- Negative:
- wrong hostname -> connection fails
- wrong CA file -> connection fails
- disable TLS on DB -> connection fails
This ensures no silent downgrade to weak TLS in production.

View File

@@ -1,26 +1,26 @@
[Unit]
Description=Secrets MCP Server
Description=Secrets API Server
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=secrets-mcp
Group=secrets-mcp
WorkingDirectory=/opt/secrets-mcp
EnvironmentFile=/opt/secrets-mcp/.env
ExecStart=/opt/secrets-mcp/secrets-mcp
User=secrets
Group=secrets
WorkingDirectory=/opt/secrets
EnvironmentFile=/opt/secrets/.env
ExecStart=/opt/secrets/secrets-api
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=secrets-mcp
SyslogIdentifier=secrets-api
# 安全加固
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/secrets-mcp
ReadWritePaths=/opt/secrets
PrivateTmp=yes
[Install]

View File

@@ -5,19 +5,6 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
version="$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
tag="secrets-mcp-${version}"
echo "==> 当前 secrets-mcp 版本: ${version}"
echo "==> 检查是否已存在 tag: ${tag}"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "提示: 已存在 tag ${tag},将按重复构建处理,不阻断检查。"
echo "如需创建新的发布版本,请先 bump crates/secrets-mcp/Cargo.toml 中的 version。"
else
echo "==> 未发现重复 tag将创建新版本"
fi
echo "==> 开始执行检查"
cargo fmt -- --check
cargo clippy --locked -- -D warnings

View File

@@ -0,0 +1 @@
entry_id,secret_name,secret_value
1 entry_id secret_name secret_value

View File

@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""
Batch re-encrypt secret fields from a CSV file.
CSV format:
entry_id,secret_name,secret_value
019d...,api_key,sk-xxxx
019d...,password,hunter2
The script groups rows by entry_id, then calls `secrets_entry_update` with `secrets_obj`
so the server re-encrypts the provided plaintext values with the current key.
Warnings:
- Keep the CSV outside version control whenever possible.
- Delete the filled CSV after the repair is complete.
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
import urllib.error
import urllib.request
from collections import OrderedDict
from pathlib import Path
from typing import Any
DEFAULT_USER_AGENT = "Cursor/3.0.12 (darwin arm64)"
REQUIRED_COLUMNS = {"entry_id", "secret_name", "secret_value"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Repair secret ciphertexts by re-submitting plaintext via secrets_entry_update."
)
parser.add_argument(
"--csv",
required=True,
help="Path to CSV file with columns: entry_id,secret_name,secret_value",
)
parser.add_argument(
"--mcp-json",
default=str(Path.home() / ".cursor" / "mcp.json"),
help="Path to mcp.json used to resolve URL and headers",
)
parser.add_argument(
"--server",
default="secrets",
help="MCP server name inside mcp.json (default: secrets)",
)
parser.add_argument("--url", help="Override MCP URL")
parser.add_argument("--auth", help="Override Authorization header value")
parser.add_argument("--encryption-key", help="Override X-Encryption-Key header value")
parser.add_argument(
"--user-agent",
default=DEFAULT_USER_AGENT,
help=f"User-Agent header (default: {DEFAULT_USER_AGENT})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Parse and print grouped updates without sending requests",
)
return parser.parse_args()
def load_mcp_config(path: str, server_name: str) -> dict[str, Any]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
servers = data.get("mcpServers", {})
if server_name not in servers:
raise KeyError(f"Server '{server_name}' not found in {path}")
return servers[server_name]
def resolve_connection_settings(args: argparse.Namespace) -> tuple[str, str, str]:
server = load_mcp_config(args.mcp_json, args.server)
headers = server.get("headers", {})
url = args.url or server.get("url")
auth = args.auth or headers.get("Authorization")
encryption_key = args.encryption_key or headers.get("X-Encryption-Key")
if not url:
raise ValueError("Missing MCP URL. Pass --url or configure it in mcp.json.")
if not auth:
raise ValueError(
"Missing Authorization header. Pass --auth or configure it in mcp.json."
)
if not encryption_key:
raise ValueError(
"Missing X-Encryption-Key. Pass --encryption-key or configure it in mcp.json."
)
return url, auth, encryption_key
def load_updates(csv_path: str) -> OrderedDict[str, OrderedDict[str, str]]:
grouped: OrderedDict[str, OrderedDict[str, str]] = OrderedDict()
with Path(csv_path).open("r", encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh)
fieldnames = set(reader.fieldnames or [])
missing = REQUIRED_COLUMNS - fieldnames
if missing:
raise ValueError(
"CSV missing required columns: " + ", ".join(sorted(missing))
)
for line_no, row in enumerate(reader, start=2):
entry_id = (row.get("entry_id") or "").strip()
secret_name = (row.get("secret_name") or "").strip()
secret_value = row.get("secret_value") or ""
if not entry_id and not secret_name and not secret_value:
continue
if not entry_id:
raise ValueError(f"Line {line_no}: entry_id is required")
if not secret_name:
raise ValueError(f"Line {line_no}: secret_name is required")
entry_group = grouped.setdefault(entry_id, OrderedDict())
if secret_name in entry_group:
raise ValueError(
f"Line {line_no}: duplicate secret_name '{secret_name}' for entry_id '{entry_id}'"
)
entry_group[secret_name] = secret_value
if not grouped:
raise ValueError("CSV contains no updates")
return grouped
def post_json(
url: str,
payload: dict[str, Any],
auth: str,
encryption_key: str,
user_agent: str,
session_id: str | None = None,
) -> tuple[int, str | None, str]:
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Authorization": auth,
"X-Encryption-Key": encryption_key,
"User-Agent": user_agent,
}
if session_id:
headers["mcp-session-id"] = session_id
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return (
resp.status,
resp.headers.get("mcp-session-id") or session_id,
resp.read().decode("utf-8"),
)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return exc.code, session_id, body
def parse_sse_json(body: str) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for line in body.splitlines():
if line.startswith("data: {"):
items.append(json.loads(line[6:]))
return items
def initialize_session(
url: str, auth: str, encryption_key: str, user_agent: str
) -> str:
status, session_id, body = post_json(
url,
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "repair-script", "version": "1.0"},
},
},
auth,
encryption_key,
user_agent,
)
if status != 200 or not session_id:
raise RuntimeError(f"initialize failed: status={status}, body={body[:500]}")
status, _, body = post_json(
url,
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
auth,
encryption_key,
user_agent,
session_id,
)
if status not in (200, 202):
raise RuntimeError(
f"notifications/initialized failed: status={status}, body={body[:500]}"
)
return session_id
def load_entry_index(
url: str, auth: str, encryption_key: str, user_agent: str, session_id: str
) -> dict[str, tuple[str, str]]:
status, _, body = post_json(
url,
{
"jsonrpc": "2.0",
"id": 999_001,
"method": "tools/call",
"params": {
"name": "secrets_entry_find",
"arguments": {
"limit": 1000,
},
},
},
auth,
encryption_key,
user_agent,
session_id,
)
items = parse_sse_json(body)
last = items[-1] if items else {"raw": body[:1000]}
if status != 200:
raise RuntimeError(
f"secrets_entry_find failed: status={status}, body={body[:500]}"
)
if "error" in last:
raise RuntimeError(f"secrets_entry_find returned error: {last}")
content = last.get("result", {}).get("content", [])
if not content:
raise RuntimeError("secrets_entry_find returned no content")
payload = json.loads(content[0]["text"])
index: dict[str, tuple[str, str]] = {}
for entry in payload.get("entries", []):
entry_id = entry.get("id")
name = entry.get("name")
folder = entry.get("folder", "")
if entry_id and name is not None:
index[entry_id] = (name, folder)
return index
def call_secrets_entry_update(
url: str,
auth: str,
encryption_key: str,
user_agent: str,
session_id: str,
request_id: int,
entry_id: str,
entry_name: str,
entry_folder: str,
secrets_obj: dict[str, str],
) -> dict[str, Any]:
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": "tools/call",
"params": {
"name": "secrets_entry_update",
"arguments": {
"id": entry_id,
"name": entry_name,
"folder": entry_folder,
"secrets_obj": secrets_obj,
# Pass the key as an argument too, so repair can still work
# even when a client/proxy mishandles custom headers.
"encryption_key": encryption_key,
},
},
}
status, _, body = post_json(
url, payload, auth, encryption_key, user_agent, session_id
)
items = parse_sse_json(body)
last = items[-1] if items else {"raw": body[:1000]}
if status != 200:
raise RuntimeError(
f"secrets_entry_update failed for {entry_id}: status={status}, body={body[:500]}"
)
return last
def main() -> int:
args = parse_args()
try:
url, auth, encryption_key = resolve_connection_settings(args)
updates = load_updates(args.csv)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"Loaded {len(updates)} entries from {args.csv}")
if args.dry_run:
for entry_id, secrets_obj in updates.items():
print(
json.dumps(
{"id": entry_id, "secrets_obj": secrets_obj},
ensure_ascii=False,
indent=2,
)
)
return 0
try:
session_id = initialize_session(url, auth, encryption_key, args.user_agent)
entry_index = load_entry_index(
url, auth, encryption_key, args.user_agent, session_id
)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
success = 0
failures = 0
for request_id, (entry_id, secrets_obj) in enumerate(updates.items(), start=2):
try:
if entry_id not in entry_index:
raise RuntimeError(
f"entry id not found in secrets_entry_find results: {entry_id}"
)
entry_name, entry_folder = entry_index[entry_id]
result = call_secrets_entry_update(
url,
auth,
encryption_key,
args.user_agent,
session_id,
request_id,
entry_id,
entry_name,
entry_folder,
secrets_obj,
)
if "error" in result:
failures += 1
print(
json.dumps(
{"id": entry_id, "status": "error", "result": result},
ensure_ascii=False,
),
file=sys.stderr,
)
else:
success += 1
print(
json.dumps(
{"id": entry_id, "status": "ok", "result": result},
ensure_ascii=False,
)
)
except Exception as exc:
failures += 1
print(f"{entry_id}: ERROR: {exc}", file=sys.stderr)
print(f"Done. success={success} failure={failures}")
return 0 if failures == 0 else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,27 +1,17 @@
#!/usr/bin/env bash
#
# 为 refining/secrets 仓库配置 Gitea Actions 所需的 Secrets 和 Variables
# 为 refining/secrets 仓库配置 v3 CI 所需的 Variables
# 参考: .gitea/workflows/secrets.yml
#
# 所需配置:
# - secrets.RELEASE_TOKEN (可选,推荐) Gitea PAT未配置则工作流跳过 Release 创建与产物上传
# - vars.WEBHOOK_URL (可选) 飞书通知
# - vars.DEPLOY_HOST (可选) 部署目标 SSH 主机IP 或域名)
# - vars.DEPLOY_USER (可选) SSH 用户名
# - secrets.DEPLOY_SSH_KEY (可选) SSH 私钥 PEM 全文(原始字符,含 BEGIN/END 行);通过 DEPLOY_SSH_KEY_FILE 写入 API
#
# 注意:
# - Gitea 不允许 secret/variable 名以 GITEA_ 或 GITHUB_ 开头,故使用 RELEASE_TOKEN
# - Gitea Actions 的 secretsAPI 的 data 字段,及网页里粘贴的值)必须是未经 base64 的原始值。
# 若事先 base64 再写入,工作流里拿到的仍是「一串 base64 文本」SSH/OpenSSH 无法识别,部署会失败。
# DEPLOY_SSH_KEY 须与 .pem 文件内容一致:本脚本用 jq --rawfile 按原文上传。
# - Variables 的 value 字段同样为原始字符串,不要 base64。
# - Variables 的 value 字段为原始字符串,不要 base64。
#
# 用法:
# 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL
# 2. 或通过环境变量覆盖: GITEA_TOKEN(作为 RELEASE_TOKEN 的值), WEBHOOK_URL,
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY_FILE部署到 ECS
# 3. 凭据勿用 base64部署私钥路径见 DEPLOY_SSH_KEY_FILE
# 2. 或通过环境变量覆盖: GITEA_TOKEN, WEBHOOK_URL
#
set -e
@@ -43,11 +33,8 @@ while [[ $# -gt 0 ]]; do
echo ""
echo "环境变量:"
echo " GITEA_URL Gitea 实例根地址(可误带尾部 /api/v1脚本会规范化后拼接"
echo " GITEA_TOKEN 用于 Release 的 PAT → secrets.RELEASE_TOKEN"
echo " GITEA_TOKEN Gitea PAT"
echo " WEBHOOK_URL 或 GITEA_WEBHOOK_URL → vars.WEBHOOK_URL可选"
echo " DEPLOY_HOST 部署 SSH 主机(可选,须与下面两项同时设置)"
echo " DEPLOY_USER 部署 SSH 用户"
echo " DEPLOY_SSH_KEY_FILE 本地 PEM 路径 → secrets.DEPLOY_SSH_KEY原文上传勿 base64"
exit 0
;;
*)
@@ -97,11 +84,10 @@ done
API_BASE="${GITEA_URL}/api/v1"
# 获取 GITEA_TOKEN作为 workflow 中 secrets.RELEASE_TOKEN 的值)
if [[ -z "$GITEA_TOKEN" ]]; then
echo "❌ GITEA_TOKEN 未配置"
echo " 在 ~/.config/gitea/config.env 中设置,或 export GITEA_TOKEN=xxx" >&2
echo " Token 需具备 repo 写权限(创建 Release、上传附件" >&2
echo " Token 需具备 repo 写权限" >&2
exit 1
fi
@@ -110,30 +96,9 @@ echo "配置 Gitea Actions: $OWNER/$REPO"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# 1. 创建 Secret: RELEASE_TOKENdata = PAT 原文,勿 base64
echo "1. 创建 Secret: RELEASE_TOKEN"
secret_payload=$(jq -n --arg t "$GITEA_TOKEN" '{data: $t}')
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$secret_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "204" ]]; then
echo " ✓ RELEASE_TOKEN 已创建/更新"
else
echo " ❌ 失败 (HTTP $http_code)" >&2
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
exit 1
fi
# 2. 创建/更新 Variable: WEBHOOK_URL可选value 为原始 URL 字符串,勿 base64
echo "1. 创建/更新 Variable: WEBHOOK_URL可选"
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
if [[ -n "$WEBHOOK_VALUE" ]]; then
echo ""
echo "2. 创建/更新 Variable: WEBHOOK_URL"
var_payload=$(jq -n --arg v "$WEBHOOK_VALUE" '{value: $v}')
resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
@@ -162,82 +127,17 @@ if [[ -n "$WEBHOOK_VALUE" ]]; then
echo " ⚠ 失败 (HTTP $http_code),飞书通知将不可用" >&2
fi
else
echo ""
echo "2. 跳过 WEBHOOK_URL未配置 GITEA_WEBHOOK_URL 或 WEBHOOK_URL"
echo " 跳过 WEBHOOK_URL未配置 GITEA_WEBHOOK_URL 或 WEBHOOK_URL"
echo " 飞书通知将不可用;如需可后续在仓库 Settings → Variables 中添加"
fi
# 3. 部署用 Variable + Secret与 .gitea/workflows/secrets.yml 中 deploy-mcp 一致)
upsert_repo_variable() {
local var_name="$1" var_value="$2"
local var_payload http_code body resp
var_payload=$(jq -n --arg v "$var_value" '{value: $v}')
resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/${var_name}")
http_code=$(echo "$resp" | tail -n1)
if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "204" ]]; then
return 0
fi
if [[ "$http_code" == "409" ]]; then
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/${var_name}")
http_code=$(echo "$resp" | tail -n1)
[[ "$http_code" == "200" || "$http_code" == "204" ]]
return
fi
body=$(echo "$resp" | sed '$d')
echo " ❌ 变量 ${var_name} 失败 (HTTP $http_code)" >&2
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
return 1
}
if [[ -n "$DEPLOY_HOST" && -n "$DEPLOY_USER" && -n "$DEPLOY_SSH_KEY_FILE" ]]; then
echo ""
echo "3. 部署目标: vars.DEPLOY_HOST / vars.DEPLOY_USER + secrets.DEPLOY_SSH_KEY"
if [[ ! -f "$DEPLOY_SSH_KEY_FILE" ]]; then
echo " ❌ DEPLOY_SSH_KEY_FILE 不是文件: $DEPLOY_SSH_KEY_FILE" >&2
exit 1
fi
upsert_repo_variable DEPLOY_HOST "$DEPLOY_HOST" || exit 1
echo " ✓ DEPLOY_HOST"
upsert_repo_variable DEPLOY_USER "$DEPLOY_USER" || exit 1
echo " ✓ DEPLOY_USER"
# PEM 原文写入 secret.data勿对文件先做 base64否则 runner 侧 ssh 无法解析密钥
secret_payload=$(jq -n --rawfile k "$DEPLOY_SSH_KEY_FILE" '{data: $k}')
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$secret_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/DEPLOY_SSH_KEY")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "204" ]]; then
echo " ✓ DEPLOY_SSH_KEY"
else
echo " ❌ DEPLOY_SSH_KEY 失败 (HTTP $http_code)" >&2
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
exit 1
fi
else
echo ""
echo "3. 跳过部署配置(需同时设置 DEPLOY_HOST、DEPLOY_USER、DEPLOY_SSH_KEY_FILE"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✓ 配置完成"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Workflow 将使用:"
echo " - secrets.RELEASE_TOKEN 创建 Release 并上传二进制"
echo " - vars.WEBHOOK_URL 发送飞书通知(如已配置)"
echo " - vars.DEPLOY_* / secrets.DEPLOY_SSH_KEY deploy-mcp如已配置"
echo " - vars.WEBHOOK_URL 发送飞书通知(如已配置)"
echo ""
echo "推送代码触发构建:"
echo " git push origin main"