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)
This commit is contained in:
agent
2026-04-13 08:49:57 +08:00
parent cb5865b958
commit 0374899dab
130 changed files with 20447 additions and 21577 deletions

View File

@@ -1,5 +1,4 @@
# MCP 分支:仅构建/发布 secrets-mcpCLI 在 main 分支维护) name: Secrets v3 CI
name: Secrets MCP — Build & Release
on: on:
push: push:
@@ -18,7 +17,6 @@ permissions:
contents: write contents: write
env: env:
MCP_BINARY: secrets-mcp
RUST_TOOLCHAIN: 1.94.0 RUST_TOOLCHAIN: 1.94.0
CARGO_INCREMENTAL: 0 CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10 CARGO_NET_RETRY: 10
@@ -28,46 +26,14 @@ env:
jobs: jobs:
ci: ci:
name: 检查 / 构建 / 发版 name: 检查
runs-on: debian runs-on: debian
timeout-minutes: 40 timeout-minutes: 40
outputs:
tag: ${{ steps.ver.outputs.tag }}
version: ${{ steps.ver.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 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"
# 版本 bump 硬检查:若本次推送包含 crates/ 或 Cargo.toml 变更,
# 但版本号与上一提交一致,则视为未发版,直接失败。
prev_version=$(git show HEAD^:crates/secrets-mcp/Cargo.toml 2>/dev/null | grep -m1 '^version' | sed 's/.*"\(.*\)".*/\1/' || true)
if [ -n "$prev_version" ] && [ "$version" = "$prev_version" ]; then
# 确认本次推送是否包含 crates/ 或 Cargo.toml 变更
if git diff --name-only HEAD^ HEAD 2>/dev/null | grep -qE '^crates/|^Cargo\.toml$'; then
echo "::error::工作区包含 crates/ 或 Cargo.toml 变更,但版本号未 bump${version} == ${prev_version}"
echo "按规则,每次代码变更必须 bump crates/secrets-mcp/Cargo.toml 中的 version。"
exit 1
fi
fi
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 工具链 ────────────────────────────────────────────────────── # ── Rust 工具链 ──────────────────────────────────────────────────────
- name: 安装 Rust 与 musl 工具链 - name: 安装 Rust 与 musl 工具链
run: | run: |
@@ -107,76 +73,13 @@ jobs:
- name: test - name: test
run: cargo test --locked run: cargo test --locked
# ── 构建(质量检查通过后才执行)──────────────────────────────────── - name: 构建 secrets-api
- name: 构建 secrets-mcp (musl)
run: | run: |
cargo build --release --locked --target "${MUSL_TARGET}" -p secrets-mcp cargo build --release --locked -p secrets-api
strip "target/${MUSL_TARGET}/release/${MCP_BINARY}"
- name: 上传构建产物 - name: 构建 secrets-desktop-daemon
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
run: | run: |
git config user.name "github-actions[bot]" cargo build --release --locked -p secrets-desktop-daemon
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} 已发布"
# ── 飞书汇总通知 ───────────────────────────────────────────────────── # ── 飞书汇总通知 ─────────────────────────────────────────────────────
- name: 飞书通知 - name: 飞书通知
@@ -185,84 +88,14 @@ jobs:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: | run: |
[ -z "$WEBHOOK_URL" ] && exit 0 [ -z "$WEBHOOK_URL" ] && exit 0
tag="${{ steps.ver.outputs.tag }}"
commit="${{ github.event.head_commit.message }}" commit="${{ github.event.head_commit.message }}"
[ -z "$commit" ] && commit="${{ github.sha }}" [ -z "$commit" ] && commit="${{ github.sha }}"
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}" result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets-mcp 构建&发版 ${icon} msg="secrets v3 CI ${icon}
版本:${tag}
提交:${commit} 提交:${commit}
作者:${{ github.actor }} 作者:${{ github.actor }}
详情:${url}" 详情:${url}"
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}') 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" 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 }}
DEPLOY_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
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
install -m 600 /dev/null /tmp/deploy_key
echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key
trap 'rm -f /tmp/deploy_key' EXIT
if [ -n "$DEPLOY_KNOWN_HOSTS" ]; then
echo "$DEPLOY_KNOWN_HOSTS" > /tmp/deploy_known_hosts
ssh_opts="-o UserKnownHostsFile=/tmp/deploy_known_hosts -o StrictHostKeyChecking=yes"
else
ssh_opts="-o StrictHostKeyChecking=accept-new"
fi
scp -i /tmp/deploy_key $ssh_opts \
"/tmp/artifact/${MCP_BINARY}" \
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new"
ssh -i /tmp/deploy_key $ssh_opts "${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)
"
- 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"

9
.gitignore vendored
View File

@@ -7,3 +7,12 @@ tmp/
client_secret_*.apps.googleusercontent.com.json client_secret_*.apps.googleusercontent.com.json
node_modules/ node_modules/
*.pyc *.pyc
# Desktop: Tauri frontend bundle (tauri.conf.json build.frontendDist)
apps/desktop/dist/
# 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

46
.vscode/tasks.json vendored
View File

@@ -1,46 +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",
"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",
"problemMatcher": []
},
{
"label": "ci: release-check",
"type": "shell",
"command": "./scripts/release-check.sh",
"problemMatcher": []
}
]
}

423
AGENTS.md
View File

@@ -1,6 +1,13 @@
# 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 业务与数据层
`secrets-core` / `secrets-mcp` / `secrets-mcp-local` 已移除,不再作为开发入口。
## 版本控制 ## 版本控制
@@ -23,202 +30,14 @@
| 拉取远端 | `jj git fetch` | | 拉取远端 | `jj git fetch` |
### 注意事项 ### 注意事项
- 本仓库为**纯 jj 模式**,无 `.git` 目录;本地不要使用 `git` 命令
- CI/CDGitea Actions仍通过 Git 协议拉取代码Runner 侧自动使用 `git`,无需修改
- 检查标签是否存在时使用 `jj log --no-graph --revisions "tag(${tag})"` 而非 `git rev-parse`
## 提交 / 推送硬规则(优先于下文) - 本仓库为纯 `jj` 模式,本地不要使用 `git` 命令。
- CI Runner 侧仍可能使用 `git` 拉代码,这不影响本地开发。
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:** - 检查 tag 是否存在时,使用 `jj log --no-graph --revisions "tag(${tag})"`
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。
2. 提交前检查 `crates/secrets-mcp/Cargo.toml``version`,再查 tag`jj tag list`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`
3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`
## 项目结构
```
secrets/
Cargo.toml
crates/
secrets-core/ # db / crypto / models / audit / service
secrets-mcp/ # rmcp tools、axum、OAuth、DashboardCHANGELOG.md → /changelog
scripts/
release-check.sh
setup-gitea-actions.sh
.gitea/workflows/secrets.yml
.vscode/tasks.json
```
## 数据库
- **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。
- **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。
- **表**`entries`(含 `user_id`)、`secrets``entries_history``secrets_history``audit_log``users``oauth_accounts`,首次连接 **auto-migrate**`secrets-core``migrate`)。
- **Web 会话**:与上项 **同一数据库 URL**`secrets-mcp` 启动时对 tower-sessions 的 PostgreSQL 存储 **auto-migrate**(会话表与业务表共存于该实例,无需第二套连接串)。
### 表结构(摘录)
```sql
entries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID, -- 多租户NULL=遗留行;非空=归属用户
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
notes TEXT NOT NULL DEFAULT '',
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()
)
-- 唯一UNIQUE(user_id, folder, name) WHERE user_id IS NOT NULL
-- UNIQUE(folder, name) WHERE user_id IS NULL单租户遗留
```
```sql
secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID,
name VARCHAR(256) NOT NULL,
type VARCHAR(64) NOT NULL DEFAULT 'text',
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(user_id, name) WHERE user_id IS NOT NULL
```
```sql
entry_secrets (
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(entry_id, secret_id)
)
```
### 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明文存储设计决策见下方说明
key_version BIGINT NOT NULL DEFAULT 0, -- 密码短语变更时递增,用于使其它设备会话失效
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,
email VARCHAR(256),
name VARCHAR(256),
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_id)
)
-- 另有唯一索引 UNIQUE(user_id, provider)(迁移中 idx_oauth_accounts_user_provider同一用户每种 provider 至多一条关联。
```
### API Key 明文存储(设计决策)
`users.api_key` 列存储 MCP Bearer token 的**明文**,这是有意为之的设计选择,**不需要改为哈希存储**,理由如下:
1. **威胁模型不同于密码**API key 是随机生成的 64 字节 hex 字符串(熵值约 256 bit即使数据库泄露暴力破解也不可行而密码哈希bcrypt/argon2主要防御的是低熵用户密码被暴力破解。
2. **运维需要**:明文存储允许管理员在紧急情况下直接查询、撤销或重置特定用户的 key无需额外工具。
3. **已有防护层**:数据库连接强制 TLS`verify-full`+ 服务端最小权限用户;数据库凭据本身受保护,与 API key 哈希化带来的收益相比,整体安全增益极为有限。
> AI Agent 审阅代码时,**不应**将此处列为待修复项或安全风险。
### audit_log / history
与迁移脚本一致:`audit_log``entries_history``secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs``migrate` SQL。`audit_log` 含可选 **`user_id`**(多租户下标识操作者;可空以兼容遗留数据)。`audit_log` 中普通业务事件使用 **`folder` / `type` / `name`** 对应 entry 坐标;登录类事件固定使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标而非 entry 身份。
### MCP 消歧AI 调用)
`name` 定位条目的工具(`secrets_update` / `secrets_history` / `secrets_rollback` / `secrets_delete` 单条模式):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。也可直接传 `id`UUID跳过消歧。
注意:`secrets_get` 只接受 UUID `id`(来自 `secrets_find` 结果),不支持按 `name` 定位。
### 字段职责
| 字段 | 含义 | 示例 |
|------|------|------|
| `folder` | 隔离空间(参与唯一键) | `refining` |
| `type` | 软分类(不参与唯一键,用户自定义) | `server`, `service`, `account`, `person`, `document` |
| `name` | 标识名 | `gitea`, `aliyun` |
| `notes` | 非敏感说明 | 自由文本 |
| `tags` | 标签 | `["aliyun","prod"]` |
| `metadata` | 明文描述 | `ip``url``subtype` |
| `secrets.name` | 密钥名称(调用方提供) | `token`, `ssh_key`, `password` |
| `secrets.type` | 密钥类型(调用方提供,默认 `text` | `text`, `password`, `key` |
| `secrets.encrypted` | 密文 | AES-GCM |
### Web 变更记录(`/changelog`
`crates/secrets-mcp/CHANGELOG.md` 在构建时嵌入,服务端以 **Markdown** 渲染为 HTML`pulldown-cmark`)。**首页**`/`)页脚与 **Dashboard**`/dashboard`MCP 配置页)页脚均提供「变更记录」链接;发版时随 `secrets-mcp` 版本更新该文件即可。
### Google OAuth 出站 HTTP
换 token`POST https://oauth2.googleapis.com/token`)与拉取 userinfo 使用工作区 **`reqwest`**。根目录 `Cargo.toml` 中为 `reqwest` 启用了 **`system-proxy`**(因 `default-features = false` 须显式打开),以便在 **macOS / Windows** 上读取**系统代理**,避免「浏览器能上 Google、服务端换 token 超时」这类代理不一致。若仅提供端口代理、系统代理未生效,可设 **`HTTPS_PROXY` / `NO_PROXY`**,见 `deploy/.env.example`
### Web JSON API 与会话
除页面路由使用的 `require_valid_user`(未登录或 `key_version` 与库不一致时重定向 `/login`JSON API`/api/...`)使用等价校验:会话中的 `key_version` 须与 `users.key_version` 一致,否则返回 **401** JSON避免仅校验 `user_id` 时与页面行为不一致。
### Web 条目页表格列(`/entries`
列表仅展示非敏感字段;**名称**与**操作**列为固定列(不可在「显示列」中关闭)。**文件夹**(对应 `entries.folder`)、类型、备注、标签、关联、密文等为**可选列**,由用户在「显示列」面板中勾选;可见性保存在浏览器 `localStorage`,键为 **`entries_col_vis`**。新增列会并入默认:若用户曾保存过旧版配置,缺失的列键会按当前默认补齐。**文件夹**列默认**显示**,便于在「全部」等跨 folder 视图下区分条目所属隔离空间。
筛选栏支持查询参数 **`tags`**(逗号分隔,多标签 **AND**,语义同 `SearchParams.tags` / `tags @> ARRAY[...]`);分页与 folder 标签计数与当前筛选一致。
### 导出 / 导入文件
JSON/TOML/YAML 导出可在每条目上包含 `secret_types`secret 名 → `text` / `password` / `key` 等),导入时写回 `secrets.type`**旧版导出无该字段**时导入仍成功,类型按 **`text`** 默认。
### 共享密钥N:N 关联)
多个 entry 可共享同一 secret 字段,通过 `entry_secrets` 中间表关联。
添加条目时通过 `link_secret_names` 参数指定要关联的已有 secret name`(user_id, name)` 精确匹配)。
删除 entry 时仅解除关联secret 本身若仍被引用则保留;不再被任何 entry 引用时自动清理。
## 代码规范
- 错误:业务层 `anyhow::Result`,避免生产路径 `unwrap()`
- 异步:`tokio` + `sqlx` async。
- SQL`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。
- 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。tracing 字段风格:变量名即字段名时用简写(`%var``?var``var`),否则用显式形式(`field = %expr`)。
- 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`不持有原始密钥。Web 客户端在浏览器本地完成加解密MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
- MCPtools 参数与 JSON Schema`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
## 生产 CORS
生产环境 CORS 使用显式请求头白名单(`build_cors_layer`),而非 `allow_headers(Any)`
因为 `tower-http` 禁止 `allow_credentials(true)``allow_headers(Any)` 同时使用。
**维护约束**:若 MCP 协议或客户端新增自定义请求头,必须同步更新 `production_allowed_headers()`
当前允许的请求头:`Authorization``Content-Type``X-Encryption-Key``mcp-session-id``x-mcp-session`
## 提交前检查 ## 提交前检查
```bash 每次提交前至少运行:
./scripts/release-check.sh
```
或手动:
```bash ```bash
cargo fmt -- --check cargo fmt -- --check
@@ -226,41 +45,197 @@ cargo clippy --locked -- -D warnings
cargo test --locked cargo test --locked
``` ```
发版前确认未重复 tag 也可以直接运行
```bash ```bash
grep '^version' crates/secrets-mcp/Cargo.toml ./scripts/release-check.sh
jj tag list
``` ```
## CI/CD ## 项目结构
- **触发**:任意分支 `push`,且路径含 `crates/**``deploy/**`、根目录 `Cargo.toml``Cargo.lock``.gitea/workflows/**`(见 `.gitea/workflows/secrets.yml`)。 ```text
- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;构建成功后打 `secrets-mcp-<version>`:若远端已存在同名 tagCI 会先删后于**当前提交**重建并推送(覆盖式发版)。 secrets/
- **质量与构建**`fmt` / `clippy --locked` / `test --locked``x86_64-unknown-linux-musl` 发布构建 `secrets-mcp` Cargo.toml
- **Release可选**`secrets.RELEASE_TOKEN`Gitea PAT用于通过 API **创建或更新**该 tag 的 Release非 draft、上传 `tar.gz` + `.sha256`;未配置则跳过 API Release仅 tag + 构建。 apps/
- **部署(可选)**:仅 `main``feat/mcp``mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow`deploy/.env.example` 在目标机配置。 api/ # 远端 JSON API
- **Secrets 写法**Actions **secrets 须为原始值**PEM、PAT 明文),**勿** base64否则 SSH/Release 会失败。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。 desktop/src-tauri/ # 桌面端
- **通知**`vars.WEBHOOK_URL`(可选,飞书)。 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-v3`
|------|------| - 连接串:`SECRETS_DATABASE_URL`
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 | - 首次连接会自动运行 `secrets-infrastructure-db::migrate_current_schema`
| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`)。 |
| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径。 |
| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 |
| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 |
| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式。 |
| `BASE_URL` | 对外基址OAuth 回调 `${BASE_URL}/auth/google/callback`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
| `RUST_LOG` | 如 `secrets_mcp=debug`。 |
| `RATE_LIMIT_GLOBAL_PER_SECOND` | 可选。全局限流速率,默认 `100` req/s。 |
| `RATE_LIMIT_GLOBAL_BURST` | 可选。全局限流突发量,默认 `200`。 |
| `RATE_LIMIT_IP_PER_SECOND` | 可选。单 IP 限流速率,默认 `20` req/s。 |
| `RATE_LIMIT_IP_BURST` | 可选。单 IP 限流突发量,默认 `40`。 |
| `TRUST_PROXY` | 可选。设为 `1`/`true`/`yes` 时从 `X-Forwarded-For` / `X-Real-IP` 提取客户端 IP。 |
> `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 授权
- 使用本地 loopback callback
- 使用 `PKCE`
- 桌面端换取 Google token 后调用 API 的桌面登录接口
- API 校验 Google userinfo 后发放本地 device token
桌面端优先读取:
- `GOOGLE_OAUTH_CLIENT_FILE`
默认开发文件名:
- `client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json`
## 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`
兼容别名:
- `secrets_find`
- `secrets_add`
- `secrets_update`
### `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/`** 前端打包目录(见根目录 `.gitignore`)。
- **图标**:仅跟踪 `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`。本地或 CI 在运行 `cargo tauri dev` / `cargo tauri build` 前,需先按项目约定生成或同步 **`apps/desktop/dist/`** 内容;流水线构建桌面端时,在 Tauri 步骤之前加入对应的前端产物步骤即可。
## 代码规范
- 业务层优先使用 `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 明文存储”机械地当成密码学问题处理,必须结合当前架构和威胁模型判断

View File

@@ -45,11 +45,12 @@ cargo test --locked
## 发版规则 ## 发版规则
涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认需要发版。 当前仓库已切换到 v3 架构,不再围绕 `secrets-mcp` 做单独发版。
1. 检查 `crates/secrets-mcp/Cargo.toml``version` 提交前请至少保证:
2. 运行 `jj tag list` 确认对应 tag 是否已存在
3. 若 tag 已存在且有代码变更,**必须 bump 版本**并 `cargo build` 同步 `Cargo.lock`
4. 通过 release-check 后再提交
详见 [AGENTS.md](AGENTS.md) 的「提交 / 推送硬规则」章节。 1. `cargo fmt -- --check`
2. `cargo clippy --locked -- -D warnings`
3. `cargo test --locked`
详见 [AGENTS.md](AGENTS.md) 中最新的仓库说明。

3581
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,14 @@
[workspace] [workspace]
members = [ members = [
"crates/secrets-core", "apps/api",
"crates/secrets-mcp", "apps/desktop/src-tauri",
"crates/secrets-mcp-local", "crates/application",
"crates/client-integrations",
"crates/crypto",
"crates/desktop-daemon",
"crates/device-auth",
"crates/domain",
"crates/infrastructure-db",
] ]
resolver = "2" resolver = "2"
@@ -14,7 +20,7 @@ edition = "2024"
tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] } tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] }
# Database # 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 # Serialization
serde = { version = "^1.0.228", features = ["derive"] } serde = { version = "^1.0.228", features = ["derive"] }
@@ -26,12 +32,13 @@ toml = "^1.0.7"
aes-gcm = "^0.10.3" aes-gcm = "^0.10.3"
sha2 = "^0.10.9" sha2 = "^0.10.9"
rand = "^0.10.0" rand = "^0.10.0"
hex = "0.4"
# Utils # Utils
anyhow = "^1.0.102" anyhow = "^1.0.102"
thiserror = "^2" thiserror = "^2"
chrono = { version = "^0.4.44", features = ["serde"] } 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 = "^0.1"
tracing-subscriber = { version = "^0.3", features = ["env-filter"] } tracing-subscriber = { version = "^0.3", features = ["env-filter"] }
dotenvy = "^0.15" dotenvy = "^0.15"
@@ -39,3 +46,9 @@ dotenvy = "^0.15"
# HTTP # HTTP
# system-proxy与浏览器一致读取 macOS/Windows 系统代理(禁用 default 后须显式开启,否则 OAuth 出站不走 Clash 等) # system-proxy与浏览器一致读取 macOS/Windows 系统代理(禁用 default 后须显式开启,否则 OAuth 出站不走 Clash 等)
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] } 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 = [] }

375
README.md
View File

@@ -1,110 +1,50 @@
# secrets-mcp # Secrets
Workspace**`secrets-core`** + **`secrets-mcp`**HTTP Streamable MCP + Web+ **`secrets-mcp-local`**(可选:本机 MCP gateway。多租户密钥与元数据存 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 ```bash
cargo build --release -p secrets-mcp cp deploy/.env.example .env
# 产物: target/release/secrets-mcp
# 远端 API
cargo run -p secrets-api --bin secrets-api
# 本地 daemon
cargo run -p secrets-desktop-daemon
# 桌面客户端
cargo run -p secrets-desktop
``` ```
```bash ## 当前能力
cargo build --release -p secrets-mcp-local
# 产物: target/release/secrets-mcp-local本机 MCP gateway见下节
```
发版产物见 Gitea Releasetag`secrets-mcp-<version>`Linux musl 预编译);其它平台本地 `cargo build` - 桌面端使用系统浏览器完成 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`
- 保留兼容别名:`secrets_find` / `secrets_add` / `secrets_update`
- 桌面端会自动把本地 daemon MCP 配置写入 `Cursor``Claude Code`
- 桌面端支持条目新建、搜索、按 type 筛选、元数据编辑、最近删除与恢复
- 桌面端支持 secret 新增、编辑、删除、明文显示、真实复制、历史查看与回滚
- 不保留 `secrets_env_map`
- 不做自动恢复登录;重启 app 后必须重新登录
## 环境变量与本地运行 ## 提交前检查
复制 `deploy/.env.example` 为项目根目录 `.env`(已在 `.gitignore`),或导出同名变量:
| 变量 | 说明 |
|------|------|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(推荐使用域名,例如 `db.refining.ltd`,避免直连 IP。 |
| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`),避免回退到弱 TLS 模式。 |
| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径(如 `/etc/secrets/pg-ca.crt`)。 |
| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式(`prefer``disable``allow``require`)。 |
| `BASE_URL` | 对外访问基址OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。换 token 须访问 `oauth2.googleapis.com`:工作区 **`reqwest` 已启用 `system-proxy`**,与浏览器一致可走 macOS/Windows **系统代理**(如 Clash 系统代理模式)。 |
| `HTTPS_PROXY` / `NO_PROXY` | 可选。仅当系统代理未被进程识别、又需走本地端口代理时设置;示例见 [`deploy/.env.example`](deploy/.env.example)。 |
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 |
| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 |
| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 |
| `RATE_LIMIT_GLOBAL_PER_SECOND` | 可选。全局限流速率,默认 `100` req/s。 |
| `RATE_LIMIT_GLOBAL_BURST` | 可选。全局限流突发量,默认 `200`。 |
| `RATE_LIMIT_IP_PER_SECOND` | 可选。单 IP 限流速率,默认 `20` req/s。 |
| `RATE_LIMIT_IP_BURST` | 可选。单 IP 限流突发量,默认 `40`。 |
| `TRUST_PROXY` | 可选。设为 `1`/`true`/`yes` 时从 `X-Forwarded-For` / `X-Real-IP` 提取客户端 IP仅在反代环境下启用。 |
```bash ```bash
cargo run -p secrets-mcp cargo fmt -- --check
``` cargo clippy --locked -- -D warnings
cargo test --locked
生产推荐示例PostgreSQL TLS
```bash
SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp
SECRETS_DATABASE_SSL_MODE=verify-full
SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
SECRETS_ENV=production
```
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key。**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 DashboardMCP页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选(含 **`tags`** 逗号分隔、多标签同时匹配);表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
- **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
### 本地 MCP gateway`secrets-mcp-local`
`secrets-mcp-local` 现在是**独立的本地 MCP 入口**,不再依赖把远程 `/mcp` 原样透传到本机。它始终能完成 MCP `initialize` / `tools/list`,但会按状态暴露不同工具面:
- `bootstrap`:尚未绑定或尚未解锁,只暴露 `local_status``local_bind_start``local_bind_exchange``local_unlock_status``local_onboarding_info`
- `pendingUnlock`:远端授权已完成,但本地仍未完成 passphrase 解锁;仍只暴露 bootstrap 工具
- `ready`:绑定 + 解锁均完成,额外暴露 `secrets_find``secrets_search``secrets_history``secrets_overview``secrets_delete(dry_run)``target_exec`
上线流程:
1. 启动 `secrets-mcp-local`
2. 在浏览器打开本地首页 `http://127.0.0.1:9316/`
3. 点击“开始绑定”,打开页面给出的 `approve_url`
4. 在远端网页确认授权后,返回本地首页等待自动进入解锁阶段
5. 在本地页面或 `/unlock` 完成浏览器内 PBKDF2 派生、`key_check` 校验与本地解锁
6. 之后将 Cursor 等客户端的 MCP URL 配为 `http://127.0.0.1:9316/mcp`
这套流程下Cursor 会先稳定连上 local MCP未就绪时 AI 只能看到 bootstrap 工具,因此会明确告诉用户去打开本地 onboarding 页面或 `approve_url`,不会再因为 `401` 被误判成“连接失败”。
运行时说明:
- local gateway 的业务数据面已切到远端 JSON HTTP API`find/search/history/overview/delete-preview/decrypt` 直接走 `/api/local-mcp/...`
- `target_exec` 首次执行某个目标时,建议同时传入 `secrets_find/search` 返回的目标摘要local gateway 会按 `entry_id` 缓存解析后的执行上下文,后续同一目标可复用而不必重新读取密钥
- 远端 `key_version` 变化时,本地会自动从 `ready` 回退到 `pendingUnlock`
- 远端 API key 已失效或绑定用户不存在时,本地会自动清除 bound 状态并重新回到 `bootstrap`
`target_exec` 运行时会注入一组标准环境变量,例如:
- `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`
- `TARGET_META_<KEY>``TARGET_SECRET_<KEY>`(对 metadata / secret 字段名做大写与下划线归一化)
典型用法:
-`secrets_find` 找到目标服务器,再用 `target_exec` 执行 `ssh -i <(printf '%s' \"$TARGET_SSH_KEY\") \"$TARGET_USER@$TARGET_HOST\" 'df -h'`
-`secrets_search` 找到 API 服务条目,再用 `target_exec` 执行 `curl -H \"Authorization: Bearer $TARGET_API_KEY\" \"$TARGET_BASE_URL/health\"`
本地状态行为:
- `POST /local/lock`:仅清除本地解锁缓存,保留绑定
- `POST /local/unbind`:同时清除本地绑定与解锁状态
- `GET /local/status`:返回 `bootstrap` / `pendingUnlock` / `ready`、待确认绑定会话、缓存目标数、`onboarding_url` / `unlock_url`
| 变量 | 说明 |
|------|------|
| `SECRETS_REMOTE_BASE_URL` | **必填**。远程 Web 基址,例如 `https://secrets.example.com`。 |
| `SECRETS_MCP_LOCAL_BIND` | 可选。监听地址,默认 `127.0.0.1:9316`。 |
| `SECRETS_LOCAL_UNLOCK_TTL_SECS` | 可选。默认解锁缓存秒数(`/local/unlock/complete` 可传 `ttl_secs` 覆盖)。 |
| `SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS` | 可选。按 `entry_id` 复用已解析执行上下文的缓存秒数;到期、`lock``unbind` 或远端 `key_version` 变化后会失效。 |
```bash
SECRETS_REMOTE_BASE_URL=https://secrets.example.com cargo run -p secrets-mcp-local
# 启动后直接打开 http://127.0.0.1:9316/
# 页面会引导你完成 bind -> approve -> unlock -> ready 全流程
``` ```
## PostgreSQL TLS 加固 ## PostgreSQL TLS 加固
@@ -113,123 +53,57 @@ SECRETS_REMOTE_BASE_URL=https://secrets.example.com cargo run -p secrets-mcp-loc
- 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA并保证证书 `SAN` 包含 `db.refining.ltd` - 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA并保证证书 `SAN` 包含 `db.refining.ltd`
- PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。 - PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。
- 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca` - 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca`
- 可执行运维步骤见 [`deploy/postgres-tls-hardening.md`](deploy/postgres-tls-hardening.md) - 可执行运维步骤见 `[deploy/postgres-tls-hardening.md](deploy/postgres-tls-hardening.md)`
## MCP 与 AI 工作流v0.3+ ## MCP 与 AI 工作流v3
条目在逻辑上以 **`(folder, name)`** 在用户内唯一(数据库唯一索引:`user_id + folder + name`)。同名可在不同 folder 下各存一条(例如 `refining/aliyun``ricnsmart/aliyun`)。 当前 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_find` | 否 | 发现条目(返回含 secret_fields schema支持 `name_query` 模糊匹配 |
| `secrets_search` | 否 | 搜索条目,支持 `query`/`folder`/`type`/`name` 过滤、`sort`/`offset` 分页、`summary` 摘要模式 |
| `secrets_get` | 是 | 按 UUID `id` 获取单条条目及解密后的 secrets |
| `secrets_add` | 是 | 添加新条目,支持 `meta_obj`/`secrets_obj` JSON 对象参数、`secret_types` 指定密钥类型、`link_secret_names` 关联已有 secret |
| `secrets_update` | 是 | 更新条目,支持 `id``name`+`folder` 定位 |
| `secrets_delete` | 否 | 删除条目,支持 `id``name`+`folder` 定位;`dry_run=true` 预览删除 |
| `secrets_history` | 否 | 查看条目历史,支持 `id``name`+`folder` 定位 |
| `secrets_rollback` | 否 | 回滚条目到指定历史版本(服务端按历史快照恢复元数据与密文关联),支持 `id`;仅需 **Bearer**,不要求 `X-Encryption-Key` |
| `secrets_export` | 是 | 导出条目(含解密明文),支持 JSON/TOML/YAML 格式 |
| `secrets_env_map` | 是 | 将 secrets 转为环境变量映射:`PREFIX_ENTRYNAME_FIELDNAME`(字段名中 `.``__``-``_` 再转大写,避免与纯下划线字段名碰撞),支持 `prefix` |
| `secrets_overview` | 否 | 返回各 folder 和 type 的 entry 计数概览 |
### 消歧规则 | 工具 | 说明 |
| --- | --- |
| `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_*` 环境变量并执行本地命令 |
- **按 `name` 定位的工具**`secrets_update` / `secrets_delete` / `secrets_history` / `secrets_rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。也可直接传 `id`UUID跳过消歧。 ### 兼容别名
- **`secrets_get`** 仅支持通过 `id`UUID获取。
- **`secrets_delete`** 的 `dry_run=true` 与真实删除使用相同消歧规则——唯一则预览一条,多条则报错并要求 `folder`
### 共享密钥 以下旧名称仍可用,但内部已转发到 v3 工具:
N:N 关联下,删除 entry 仅解除关联,被共享的 secret 若仍被其他 entry 引用则保留;无引用时自动清理。 - `secrets_find` -> `secrets_entry_find`
- `secrets_add` -> `secrets_entry_add`
## 加密架构(混合 E2EE - `secrets_update` -> `secrets_entry_update`
### 密钥派生
用户在 Web Dashboard 设置**密码短语**,浏览器使用 **Web Crypto APIPBKDF2-SHA256600k 次迭代)**在本地派生 256-bit AES 密钥。
- **Salt32B**:首次设置时在浏览器生成,存入服务端 `users.key_salt`
- **key_check**:派生密钥加密已知常量 `"secrets-mcp-key-check"`,存入 `users.key_check`,用于登录时验证密码短语
- **服务端不存储原始密钥**,只存 salt + key_check
跨设备同步:新设备登录 → 输入相同密码短语 → 从服务端取 salt → 同样的 PBKDF2 → 得到相同密钥。
### 写入与读取流程
```mermaid
flowchart LR
subgraph Web["Web 浏览器E2E"]
P["密码短语"] --> K["PBKDF2 → 256-bit key"]
K --> Enc["AES-256-GCM 加密"]
K --> Dec["AES-256-GCM 解密"]
end
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
```
### 两种客户端对比
| | Web 浏览器 | AI 客户端MCP |
|---|---|---|
| 密钥位置 | 仅在浏览器内存 / sessionStorage | MCP 配置 headers 中 |
| 加解密位置 | 客户端(真正 E2E | 服务端临时(请求级生命周期) |
| 安全边界 | 服务端零知识 | 依赖 TLS + 服务端内存隔离 |
### 敏感数据传输
- **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器
- **API Key** 当前存放在 `users.api_key`Dashboard 会明文展示并可重置
- **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化)
- **生产环境必须走 HTTPS/TLS**
## AI 客户端配置 ## AI 客户端配置
在 Web Dashboard 设置密码短语后,解锁页面会按客户端格式生成配置。常见客户端示例如下 桌面端会自动把本地 daemon 写入以下配置
`Cursor / Claude Desktop` 风格: - `~/.cursor/mcp.json`
- `~/.claude/mcp.json`
写入示例:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"secrets": { "secrets": {
"url": "https://secrets.example.com/mcp", "url": "http://127.0.0.1:9515/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"
}
} }
} }
} }
@@ -237,77 +111,76 @@ flowchart LR
## 数据模型 ## 数据模型
主表 **`entries`**`folder``type``name``notes``tags``metadata`,多租户时带 `user_id`+ 子表 **`secrets`**(每行一个加密字段:`name``type``encrypted`,通过 `entry_secrets` 中间表与 entry 建立 N:N 关联)。**唯一性**`UNIQUE(user_id, folder, name)``user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history``secrets_history``audit_log`,以及 **`users`**(含 `key_salt``key_check``key_params``api_key`)、**`oauth_accounts`**、**`local_mcp_bind_sessions`**(短时本地绑定确认会话)。首次连库自动迁移建表(`secrets-core``migrate`);已有库在进程启动时亦由同一 `migrate()` 增量补齐表、索引与 N:N 结构。若需从更早版本对照一次性 SQL可在 git 历史中检索已移除的 `scripts/migrate-v0.3.0.sql`。**Web 登录会话**tower-sessions使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp``PostgresStore::migrate`),无需额外环境变量。 当前 v3 已切到**零知识同步模型**
- 服务端保存 `vault_objects``vault_object_revisions`
- desktop 本地保存 `vault_objects``vault_object_history``pending_changes``sync_state`
- 搜索、详情、reveal、history 主要在本地已解锁 vault 上完成
- 服务端负责 `auth/device``/sync/*`,不再承担明文搜索与明文 reveal
主要表:
- `users`
- `oauth_accounts`
- `devices`
- `device_login_tokens`
- `auth_events`
- `vault_objects`
- `vault_object_revisions`
字段职责:
| 位置 | 字段 | 说明 | | 位置 | 字段 | 说明 |
|------|------|------| | --- | --- | --- |
| entries | folder | 组织/隔离空间,如 `refining``ricnsmart`;参与唯一键 | | `vault_objects` | `object_id` | 同步对象标识 |
| entries | type | 软分类,用户自定义,如 `server``service``account``person``document`(不参与唯一键) | | `vault_objects` | `object_kind` | 当前对象类别,当前主要为 `cipher` |
| entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 | | `vault_objects` | `revision` | 服务端对象版本 |
| entries | notes | 非敏感说明文本 | | `vault_objects` | `ciphertext` | 密文对象载荷 |
| entries | metadata | 明文 JSONip、url、subtype 等) | | `vault_objects` | `content_hash` | 密文摘要 |
| secrets | name | 密钥名称(调用方提供) | | `vault_objects` | `deleted_at` | 对象级删除标记 |
| secrets | type | 密钥类型(调用方提供,默认 `text` | | `vault_object_revisions` | `revision` / `ciphertext` | 服务端对象历史版本 |
| secrets | encrypted | AES-GCM 密文(含 nonce |
| users | key_salt | PBKDF2 salt32B首次设置密码短语时写入 |
| users | key_check | 派生密钥加密已知常量,用于验证密码短语 |
| users | key_params | 派生算法参数,如 `{"alg":"pbkdf2-sha256","iterations":600000}` |
### 共享密钥N:N 关联) ## 认证与事件
多个条目可共享同一密文字段,通过 `entry_secrets` 中间表实现 N:N 关联 当前登录流为 Google Desktop OAuth
- 添加条目时可通过 `link_secret_names` 参数关联已有的 secret`(user_id, name)` 精确匹配查找)
- 同一 secret 可被多个 entry 引用,删除某 entry 不会级联删除被共享的 secret
- 当 secret 不再被任何 entry 引用时,自动清理(`NOT EXISTS` 子查询)
### 类型Type - 桌面端使用系统浏览器拉起 Google 授权
- 使用本地 loopback callback + PKCE
`type` 字段用于软分类,由用户自由填写,不做任何自动转换或归一化。常见示例:`server``service``account``person``document`,但任何值均可接受。 - API 校验 Google userinfo 后发放 `device token`
- 登录与设备活动写入 `auth_events`
## 审计日志
`add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。多租户场景下可写 **`user_id`**(可空,兼容遗留行)。
业务条目事件使用 **`folder` / `type` / `name`**;登录类事件使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标(例如 `oauth` / `google`),不表示某条 secrets entry。
```sql
SELECT action, folder, type, name, detail, user_id, created_at
FROM audit_log
ORDER BY created_at DESC
LIMIT 20;
```
## 项目结构 ## 项目结构
``` ```text
Cargo.toml Cargo.toml
crates/secrets-core/ # db / crypto / models / audit / service apps/
src/ api/ # 远端 JSON API
taxonomy.rs # SECRET_TYPE_OPTIONSsecret 字段类型下拉选项) desktop/src-tauri/ # Tauri 桌面端
service/ # 业务逻辑add, search, update, delete, export, env_map 等) crates/
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API KeyCHANGELOG.md 嵌入 /changelog application/ # v3 应用服务
crates/secrets-mcp-local/ # 可选:本机 MCP gatewaybootstrap + ready 双工具面) client-integrations/ # Cursor / Claude Code mcp.json 注入
scripts/ crypto/ # 通用加密辅助
release-check.sh # 发版前 fmt / clippy / test desktop-daemon/ # 本地 MCP daemon
setup-gitea-actions.sh device-auth/ # Desktop OAuth / device token 辅助
sync-test-to-prod.sh # 测试库同步到生产(按需) domain/ # 领域模型
infrastructure-db/ # PostgreSQL 连接与迁移
deploy/ deploy/
.env.example # 环境变量模板 .env.example
secrets-mcp.service # systemd 服务文件(生产部署用) secrets-mcp.service
postgres-tls-hardening.md # PostgreSQL TLS 加固运维手册 postgres-tls-hardening.md
scripts/
release-check.sh
setup-gitea-actions.sh
``` ```
## CI/CDGitea Actions ## 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``.gitea/workflows/**` 提交前建议直接运行:
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl``secrets-mcp` → 构建成功后打 tag `secrets-mcp-<version>`(若远端已存在同名 tag会先删除再于**当前提交**重建并推送,覆盖式发版)。
- **Release可选**:配置仓库 Secret `RELEASE_TOKEN`Gitea PAT明文勿 base64会通过 API **创建或更新**已指向该 tag 的 Release非 draft、上传 `tar.gz``.sha256`;未配置则跳过 API 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 ```bash
./scripts/setup-gitea-actions.sh # 通过 Gitea API 写入 RELEASE_TOKEN、WEBHOOK_URL、部署相关变量等 ./scripts/release-check.sh
``` ```
详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。 详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。

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

@@ -0,0 +1,27 @@
[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
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(())
}

568
apps/api/src/main.rs Normal file
View File

@@ -0,0 +1,568 @@
use anyhow::{Context, Result as AnyResult};
use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderMap, StatusCode, header},
routing::{get, post},
};
use chrono::{DateTime, Utc};
use reqwest::Client;
use secrets_application::sync::{fetch_object, sync_pull, sync_push};
use secrets_device_auth::{
hash_device_login_token, new_device_fingerprint, new_device_login_token,
};
use secrets_domain::{
SyncPullRequest, SyncPullResponse, SyncPushRequest, SyncPushResponse, VaultObjectEnvelope,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use tracing_subscriber::EnvFilter;
use uuid::Uuid;
#[derive(Clone)]
struct AppState {
pool: PgPool,
http: Client,
}
#[derive(Serialize)]
struct DemoLoginResponse {
device_token: String,
}
#[derive(Debug, Deserialize)]
struct DesktopGoogleLoginRequest {
access_token: String,
device_name: String,
platform: String,
client_version: String,
device_fingerprint: String,
}
#[derive(Debug, Deserialize)]
struct GoogleUserInfo {
email: String,
name: Option<String>,
}
#[derive(Serialize)]
struct DeviceView {
name: String,
platform: String,
client_version: String,
last_seen: String,
ip: Option<String>,
}
#[derive(Serialize)]
struct UserProfileView {
id: Uuid,
name: String,
email: String,
}
#[derive(Serialize, sqlx::FromRow)]
struct UserRow {
id: Uuid,
email: Option<String>,
name: String,
}
#[derive(Serialize, sqlx::FromRow)]
struct DeviceRow {
id: Uuid,
display_name: String,
platform: String,
client_version: String,
last_seen_at: DateTime<Utc>,
last_ip: Option<String>,
}
#[derive(Debug, Serialize)]
struct ObjectResponse {
object: VaultObjectEnvelope,
}
#[tokio::main]
async fn main() -> AnyResult<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "secrets_api=info".into()),
)
.init();
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")?;
let bind = std::env::var("SECRETS_API_BIND").unwrap_or_else(|_| "127.0.0.1:9415".to_string());
let app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/auth/demo-login", post(api_demo_login))
.route("/auth/google/desktop-login", post(api_google_desktop_login))
.route("/me", get(api_me))
.route("/sync/pull", post(api_sync_pull))
.route("/sync/push", post(api_sync_push))
.route("/sync/objects/{id}", get(api_sync_object))
.route("/devices", get(api_devices))
.with_state(AppState {
pool,
http: Client::new(),
});
let listener = tokio::net::TcpListener::bind(&bind)
.await
.with_context(|| format!("failed to bind {}", bind))?;
tracing::info!(bind = %bind, "secrets-api listening");
axum::serve(listener, app)
.await
.context("api server error")?;
Ok(())
}
async fn api_demo_login(
State(state): State<AppState>,
) -> std::result::Result<Json<DemoLoginResponse>, (StatusCode, Json<serde_json::Value>)> {
let (user_id, device_id) = ensure_demo_user(&state.pool)
.await
.map_err(internal_error)?;
let device_token = new_device_login_token();
let token_hash = hash_device_login_token(&device_token);
sqlx::query("DELETE FROM device_login_tokens WHERE device_id = $1")
.bind(device_id)
.execute(&state.pool)
.await
.map_err(internal_error)?;
sqlx::query(
r#"
INSERT INTO device_login_tokens (device_id, token_hash)
VALUES ($1, $2)
"#,
)
.bind(device_id)
.bind(token_hash)
.execute(&state.pool)
.await
.map_err(internal_error)?;
sqlx::query(
r#"
INSERT INTO auth_events (
user_id, device_id, device_name, platform, client_version, ip_addr, forwarded_ip, login_method, login_result
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'device_token', 'success')
"#,
)
.bind(user_id)
.bind(device_id)
.bind("Voson 的 Mac mini")
.bind("macOS")
.bind(env!("CARGO_PKG_VERSION"))
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(&state.pool)
.await
.map_err(internal_error)?;
Ok(Json(DemoLoginResponse { device_token }))
}
async fn api_google_desktop_login(
State(state): State<AppState>,
Json(payload): Json<DesktopGoogleLoginRequest>,
) -> std::result::Result<Json<DemoLoginResponse>, (StatusCode, Json<serde_json::Value>)> {
let google_user = state
.http
.get("https://openidconnect.googleapis.com/v1/userinfo")
.bearer_auth(&payload.access_token)
.send()
.await
.map_err(internal_error)?
.error_for_status()
.map_err(internal_error)?
.json::<GoogleUserInfo>()
.await
.map_err(internal_error)?;
let user_id = upsert_user_from_google(&state.pool, &google_user)
.await
.map_err(internal_error)?;
let device_id = upsert_device_for_login(
&state.pool,
user_id,
&payload.device_name,
&payload.platform,
&payload.client_version,
&payload.device_fingerprint,
)
.await
.map_err(internal_error)?;
let device_token = issue_device_login_token(
&state.pool,
user_id,
device_id,
&payload.device_name,
&payload.platform,
&payload.client_version,
)
.await
.map_err(internal_error)?;
Ok(Json(DemoLoginResponse { device_token }))
}
async fn api_sync_pull(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<SyncPullRequest>,
) -> std::result::Result<Json<SyncPullResponse>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
let response = sync_pull(&state.pool, user.id, payload)
.await
.map_err(internal_error)?;
Ok(Json(response))
}
async fn api_sync_push(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<SyncPushRequest>,
) -> std::result::Result<Json<SyncPushResponse>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
let response = sync_push(&state.pool, user.id, payload)
.await
.map_err(internal_error)?;
Ok(Json(response))
}
async fn api_sync_object(
State(state): State<AppState>,
headers: HeaderMap,
Path(object_id): Path<Uuid>,
) -> std::result::Result<Json<ObjectResponse>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
let object = fetch_object(&state.pool, user.id, object_id)
.await
.map_err(internal_error)?
.ok_or_else(|| unauthorized("object not found"))?;
Ok(Json(ObjectResponse { object }))
}
async fn api_devices(
State(state): State<AppState>,
headers: HeaderMap,
) -> std::result::Result<Json<Vec<DeviceView>>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
let rows = sqlx::query_as::<_, DeviceRow>(
r#"
SELECT
d.id,
d.display_name,
d.platform,
d.client_version,
d.last_seen_at,
COALESCE(NULLIF(a.forwarded_ip, ''), NULLIF(a.ip_addr, '')) AS last_ip
FROM devices d
LEFT JOIN LATERAL (
SELECT ip_addr, forwarded_ip
FROM auth_events
WHERE device_id = d.id
ORDER BY created_at DESC
LIMIT 1
) a ON TRUE
WHERE d.user_id = $1
ORDER BY last_seen_at DESC
"#,
)
.bind(user.id)
.fetch_all(&state.pool)
.await
.map_err(internal_error)?;
let devices = rows
.into_iter()
.map(|row| DeviceView {
name: row.display_name,
platform: row.platform,
client_version: row.client_version,
last_seen: row.last_seen_at.format("%Y-%m-%d %H:%M").to_string(),
ip: row.last_ip,
})
.collect();
Ok(Json(devices))
}
async fn api_me(
State(state): State<AppState>,
headers: HeaderMap,
) -> std::result::Result<Json<UserProfileView>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
Ok(Json(UserProfileView {
id: user.id,
name: user.name,
email: user.email.unwrap_or_default(),
}))
}
async fn require_auth(
pool: &PgPool,
headers: &HeaderMap,
) -> std::result::Result<(UserRow, DeviceRow), (StatusCode, Json<serde_json::Value>)> {
let auth = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer "))
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| unauthorized("missing bearer token"))?;
let token_hash = hash_device_login_token(auth);
let row = sqlx::query_as::<_, DeviceRow>(
r#"
SELECT
d.id,
d.display_name,
d.platform,
d.client_version,
d.last_seen_at,
NULL::text AS last_ip
FROM device_login_tokens t
JOIN devices d ON d.id = t.device_id
WHERE t.token_hash = $1
"#,
)
.bind(&token_hash)
.fetch_optional(pool)
.await
.map_err(internal_error)?
.ok_or_else(|| unauthorized("invalid device token"))?;
sqlx::query("UPDATE device_login_tokens SET last_seen_at = NOW() WHERE token_hash = $1")
.bind(&token_hash)
.execute(pool)
.await
.map_err(internal_error)?;
sqlx::query("UPDATE devices SET last_seen_at = NOW() WHERE id = $1")
.bind(row.id)
.execute(pool)
.await
.map_err(internal_error)?;
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT u.id, u.email, u.name
FROM users u
JOIN devices d ON d.user_id = u.id
WHERE d.id = $1
"#,
)
.bind(row.id)
.fetch_one(pool)
.await
.map_err(internal_error)?;
Ok((user, row))
}
async fn ensure_demo_user(pool: &PgPool) -> AnyResult<(Uuid, Uuid)> {
let existing =
sqlx::query_as::<_, UserRow>("SELECT id, email, name FROM users WHERE email = $1 LIMIT 1")
.bind("voson.wang.s@gmail.com")
.fetch_optional(pool)
.await?;
let user_id = if let Some(user) = existing {
user.id
} else {
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id
"#,
)
.bind("voson.wang.s@gmail.com")
.bind("Voson")
.fetch_one(pool)
.await?
};
let existing_device = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM devices WHERE user_id = $1 AND display_name = $2 LIMIT 1",
)
.bind(user_id)
.bind("Voson 的 Mac mini")
.fetch_optional(pool)
.await?;
let device_id = if let Some(id) = existing_device {
id
} else {
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO devices (user_id, display_name, platform, client_version, device_fingerprint)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
"#,
)
.bind(user_id)
.bind("Voson 的 Mac mini")
.bind("macOS")
.bind(env!("CARGO_PKG_VERSION"))
.bind(new_device_fingerprint())
.fetch_one(pool)
.await?
};
Ok((user_id, device_id))
}
async fn upsert_user_from_google(pool: &PgPool, google_user: &GoogleUserInfo) -> AnyResult<Uuid> {
let existing = sqlx::query_scalar::<_, Uuid>("SELECT id FROM users WHERE email = $1 LIMIT 1")
.bind(&google_user.email)
.fetch_optional(pool)
.await?;
if let Some(user_id) = existing {
sqlx::query("UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2")
.bind(
google_user
.name
.clone()
.unwrap_or_else(|| google_user.email.clone()),
)
.bind(user_id)
.execute(pool)
.await?;
return Ok(user_id);
}
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id
"#,
)
.bind(&google_user.email)
.bind(
google_user
.name
.clone()
.unwrap_or_else(|| google_user.email.clone()),
)
.fetch_one(pool)
.await
.context("failed to create user from google login")
}
async fn upsert_device_for_login(
pool: &PgPool,
user_id: Uuid,
device_name: &str,
platform: &str,
client_version: &str,
device_fingerprint: &str,
) -> AnyResult<Uuid> {
let existing = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM devices WHERE user_id = $1 AND device_fingerprint = $2 LIMIT 1",
)
.bind(user_id)
.bind(device_fingerprint)
.fetch_optional(pool)
.await?;
if let Some(device_id) = existing {
sqlx::query(
r#"
UPDATE devices
SET display_name = $1, platform = $2, client_version = $3, last_seen_at = NOW()
WHERE id = $4
"#,
)
.bind(device_name)
.bind(platform)
.bind(client_version)
.bind(device_id)
.execute(pool)
.await?;
return Ok(device_id);
}
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO devices (user_id, display_name, platform, client_version, device_fingerprint)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
"#,
)
.bind(user_id)
.bind(device_name)
.bind(platform)
.bind(client_version)
.bind(device_fingerprint)
.fetch_one(pool)
.await
.context("failed to create device")
}
async fn issue_device_login_token(
pool: &PgPool,
user_id: Uuid,
device_id: Uuid,
device_name: &str,
platform: &str,
client_version: &str,
) -> AnyResult<String> {
let device_token = new_device_login_token();
let token_hash = hash_device_login_token(&device_token);
sqlx::query("DELETE FROM device_login_tokens WHERE device_id = $1")
.bind(device_id)
.execute(pool)
.await?;
sqlx::query("INSERT INTO device_login_tokens (device_id, token_hash) VALUES ($1, $2)")
.bind(device_id)
.bind(token_hash)
.execute(pool)
.await?;
sqlx::query(
r#"
INSERT INTO auth_events (
user_id, device_id, device_name, platform, client_version, ip_addr, forwarded_ip, login_method, login_result
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'google_desktop', 'success')
"#,
)
.bind(user_id)
.bind(device_id)
.bind(device_name)
.bind(platform)
.bind(client_version)
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(pool)
.await?;
Ok(device_token)
}
fn internal_error<E: std::fmt::Display>(error: E) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": error.to_string() })),
)
}
fn unauthorized(message: &str) -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": message })))
}

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

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

@@ -13,7 +13,6 @@ const MAX_OUTPUT_CHARS: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct TargetExecInput { pub struct TargetExecInput {
pub target_ref: Option<String>, pub target_ref: Option<String>,
pub target: Option<crate::target::TargetSnapshot>,
pub command: String, pub command: String,
pub timeout_secs: Option<u64>, pub timeout_secs: Option<u64>,
pub working_dir: Option<String>, pub working_dir: Option<String>,
@@ -138,63 +137,3 @@ pub async fn execute_command(
stderr_truncated, stderr_truncated,
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::target::ExecutionTarget;
use serde_json::json;
#[tokio::test]
async fn execute_command_injects_target_env() {
let target = ExecutionTarget {
resolved: ResolvedTarget {
id: "entry-1".to_string(),
folder: "refining".to_string(),
name: "api".to_string(),
entry_type: Some("service".to_string()),
},
env: BTreeMap::from([
("TARGET_HOST".to_string(), "47.238.146.244".to_string()),
("TARGET_API_KEY".to_string(), "sk_test_123".to_string()),
]),
};
let input = TargetExecInput {
target_ref: Some("entry-1".to_string()),
target: None,
command: "printf '%s|%s' \"$TARGET_HOST\" \"$TARGET_API_KEY\"".to_string(),
timeout_secs: Some(5),
working_dir: None,
env_overrides: None,
};
let result = execute_command(&input, &target, 5).await.unwrap();
assert_eq!(result.exit_code, Some(0));
assert_eq!(result.stdout, "47.238.146.244|sk_test_123");
}
#[tokio::test]
async fn execute_command_rejects_reserved_target_override() {
let target = ExecutionTarget {
resolved: ResolvedTarget {
id: "entry-1".to_string(),
folder: "refining".to_string(),
name: "api".to_string(),
entry_type: Some("service".to_string()),
},
env: BTreeMap::from([("TARGET_HOST".to_string(), "47.238.146.244".to_string())]),
};
let input = TargetExecInput {
target_ref: Some("entry-1".to_string()),
target: None,
command: "echo test".to_string(),
timeout_secs: Some(5),
working_dir: None,
env_overrides: Some(serde_json::from_value(json!({"TARGET_HOST":"override"})).unwrap()),
};
let err = execute_command(&input, &target, 5).await.unwrap_err();
assert!(
err.to_string()
.contains("cannot override reserved TARGET_* variables")
);
}
}

View File

@@ -0,0 +1,684 @@
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. Legacy aliases secrets_find, secrets_add, and secrets_update remain supported."
}
});
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"]
}
}),
json!({
"name": "secrets_find",
"description": "Legacy alias for secrets_entry_find.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": ["string", "null"] },
"folder": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] }
}
}
}),
json!({
"name": "secrets_add",
"description": "Legacy alias for secrets_entry_add.",
"inputSchema": {
"type": "object",
"properties": {
"folder": { "type": "string" },
"name": { "type": "string" },
"type": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] },
"secrets": { "type": ["array", "null"] }
},
"required": ["folder", "name"]
}
}),
json!({
"name": "secrets_update",
"description": "Legacy alias for secrets_entry_update.",
"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"]
}
}),
]
}
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_find" | "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_add" | "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_update" | "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

@@ -19,8 +19,6 @@ pub struct TargetSnapshot {
#[serde(rename = "type")] #[serde(rename = "type")]
pub entry_type: Option<String>, pub entry_type: Option<String>,
#[serde(default)] #[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub metadata: Map<String, Value>, pub metadata: Map<String, Value>,
#[serde(default)] #[serde(default)]
pub secret_fields: Vec<SecretFieldRef>, pub secret_fields: Vec<SecretFieldRef>,
@@ -116,9 +114,6 @@ pub fn build_execution_target(
if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) { if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) {
env.insert("TARGET_TYPE".to_string(), entry_type.clone()); env.insert("TARGET_TYPE".to_string(), entry_type.clone());
} }
if let Some(notes) = snapshot.notes.as_ref().filter(|v| !v.is_empty()) {
env.insert("TARGET_NOTES".to_string(), notes.clone());
}
for (key, value) in &snapshot.metadata { for (key, value) in &snapshot.metadata {
if let Some(value) = stringify_value(value) { if let Some(value) = stringify_value(value) {
@@ -212,52 +207,126 @@ pub fn build_execution_target(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serde_json::json;
#[test] fn build_snapshot() -> TargetSnapshot {
fn build_execution_target_maps_common_aliases() { let mut metadata = Map::new();
let snapshot = TargetSnapshot { 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(), id: "entry-1".to_string(),
folder: "refining".to_string(), folder: "infra".to_string(),
name: "hk_api_hub".to_string(), name: "production".to_string(),
entry_type: Some("server".to_string()), entry_type: Some("ssh_key".to_string()),
notes: None, metadata,
metadata: serde_json::from_value(json!({
"public_ip": "47.238.146.244",
"username": "ecs-user",
"base_url": "https://api.refining.dev"
}))
.unwrap(),
secret_fields: vec![ secret_fields: vec![
SecretFieldRef { SecretFieldRef {
name: "api_key".to_string(), name: "api_key".to_string(),
secret_type: None, secret_type: Some("text".to_string()),
}, },
SecretFieldRef { SecretFieldRef {
name: "hk-20240726.pem".to_string(), name: "token".to_string(),
secret_type: Some("text".to_string()),
},
SecretFieldRef {
name: "ssh_key".to_string(),
secret_type: Some("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([ let secrets = HashMap::from([
("api_key".to_string(), json!("sk_test_123")), ("api_key".to_string(), Value::String("ak-123".to_string())),
("token".to_string(), Value::String("tok-456".to_string())),
( (
"hk-20240726.pem".to_string(), "ssh_key".to_string(),
json!("-----BEGIN PRIVATE KEY-----"), Value::String("-----BEGIN KEY-----".to_string()),
), ),
]); ]);
let target = build_execution_target(&snapshot, &secrets).unwrap(); let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
assert_eq!(target.env.get("TARGET_HOST").unwrap(), "47.238.146.244");
assert_eq!(target.env.get("TARGET_USER").unwrap(), "ecs-user");
assert_eq!( assert_eq!(
target.env.get("TARGET_BASE_URL").unwrap(), target.env.get("TARGET_ENTRY_ID").map(String::as_str),
"https://api.refining.dev" Some("entry-1")
); );
assert_eq!(target.env.get("TARGET_API_KEY").unwrap(), "sk_test_123");
assert_eq!( assert_eq!(
target.env.get("TARGET_SSH_KEY").unwrap(), target.env.get("TARGET_NAME").map(String::as_str),
"-----BEGIN PRIVATE KEY-----" 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,108 @@
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 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,27 +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
thiserror.workspace = true
chrono.workspace = true
hex = "0.4"
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.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 FOLDER_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,
entry_type: &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, folder, type, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(ACTION_LOGIN)
.bind(FOLDER_AUTH)
.bind(entry_type)
.bind(provider)
.bind(&detail)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, entry_type, provider, "failed to write login audit log");
} else {
tracing::debug!(entry_type, 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,
folder: &str,
entry_type: &str,
name: &str,
detail: Value,
) {
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, folder, type, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(action)
.bind(folder)
.bind(entry_type)
.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, folder, entry_type, 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,71 +0,0 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use sqlx::postgres::PgSslMode;
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
pub url: String,
pub ssl_mode: Option<PgSslMode>,
pub ssl_root_cert: Option<PathBuf>,
}
/// 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"
)
}
fn env_var_non_empty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
}
fn parse_ssl_mode_from_env() -> Result<Option<PgSslMode>> {
let Some(mode) = env_var_non_empty("SECRETS_DATABASE_SSL_MODE") else {
return Ok(None);
};
let parsed = mode.parse::<PgSslMode>().with_context(|| {
format!(
"Invalid SECRETS_DATABASE_SSL_MODE='{mode}'. Use one of: disable, allow, prefer, require, verify-ca, verify-full."
)
})?;
Ok(Some(parsed))
}
fn resolve_ssl_root_cert_from_env() -> Result<Option<PathBuf>> {
let Some(path) = env_var_non_empty("SECRETS_DATABASE_SSL_ROOT_CERT") else {
return Ok(None);
};
let path = PathBuf::from(path);
if !path.exists() {
anyhow::bail!(
"SECRETS_DATABASE_SSL_ROOT_CERT points to a missing file: {}",
path.display()
);
}
Ok(Some(path))
}
pub fn resolve_db_config(override_url: &str) -> Result<DatabaseConfig> {
Ok(DatabaseConfig {
url: resolve_db_url(override_url)?,
ssl_mode: parse_ssl_mode_from_env()?,
ssl_root_cert: resolve_ssl_root_cert_from_env()?,
})
}

View File

@@ -1,128 +0,0 @@
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, AeadCore, KeyInit, OsRng},
};
use anyhow::{Context, Result, bail};
use serde_json::Value;
use crate::error::AppError;
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(|_| AppError::DecryptionFailed.into())
}
// ─── 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")
}
// ─── 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_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)
}
// ─── Public hex helpers ───────────────────────────────────────────────────────
pub mod hex {
use anyhow::Result;
pub fn encode_hex(bytes: &[u8]) -> String {
::hex::encode(bytes)
}
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
Ok(::hex::decode(s.trim())?)
}
}
#[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);
}
}

View File

@@ -1,657 +0,0 @@
use std::str::FromStr;
use anyhow::{Context, Result};
use serde_json::{Map, Value};
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use crate::config::DatabaseConfig;
fn build_connect_options(config: &DatabaseConfig) -> Result<PgConnectOptions> {
let mut options = PgConnectOptions::from_str(&config.url)
.with_context(|| "failed to parse SECRETS_DATABASE_URL".to_string())?;
if let Some(mode) = config.ssl_mode {
options = options.ssl_mode(mode);
}
if let Some(path) = &config.ssl_root_cert {
options = options.ssl_root_cert(path);
}
Ok(options)
}
pub async fn create_pool(config: &DatabaseConfig) -> Result<PgPool> {
tracing::debug!("connecting to database");
let connect_options = build_connect_options(config)?;
// Connection pool configuration from environment
let max_connections = std::env::var("SECRETS_DATABASE_POOL_SIZE")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(10);
let acquire_timeout_secs = std::env::var("SECRETS_DATABASE_ACQUIRE_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(5);
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs))
.max_lifetime(std::time::Duration::from_secs(1800)) // 30 minutes
.idle_timeout(std::time::Duration::from_secs(600)) // 10 minutes
.connect_with(connect_options)
.await?;
tracing::debug!(
max_connections,
acquire_timeout_secs,
"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,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
notes TEXT NOT NULL DEFAULT '',
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(),
deleted_at TIMESTAMPTZ
);
-- Legacy unique constraint without user_id (single-user mode)
-- NOTE: These are rebuilt below with `deleted_at IS NULL` for soft-delete support.
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
ON entries(folder, name)
WHERE user_id IS NULL;
-- Multi-user unique constraint
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
ON entries(user_id, folder, name)
WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_entries_folder ON entries(folder) WHERE folder <> '';
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type) WHERE type <> '';
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(),
user_id UUID,
name VARCHAR(256) NOT NULL,
type VARCHAR(64) NOT NULL DEFAULT 'text',
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()
);
CREATE INDEX IF NOT EXISTS idx_secrets_user_id ON secrets(user_id) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_unique_user_name
ON secrets(user_id, name) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name);
CREATE INDEX IF NOT EXISTS idx_secrets_type ON secrets(type);
-- ── entry_secrets: N:N relation ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS entry_secrets (
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(entry_id, secret_id)
);
CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id);
-- ── entry_relations: parent-child links between entries ──────────────────
CREATE TABLE IF NOT EXISTS entry_relations (
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(parent_entry_id, child_entry_id),
CHECK (parent_entry_id <> child_entry_id)
);
CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id);
CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_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,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
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_folder_type ON audit_log(folder, type);
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,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
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_folder_type_name
ON entries_history(folder, type, 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;
-- Backfill: add notes to entries if not present (fresh installs already have it)
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- ── secrets_history: field-level snapshot ────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
secret_id UUID NOT NULL,
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_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;
-- ── 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);
-- ── local_mcp_bind_sessions: short-lived browser approval state ──────────
CREATE TABLE IF NOT EXISTS local_mcp_bind_sessions (
bind_id TEXT PRIMARY KEY,
device_code TEXT NOT NULL,
user_id UUID,
approved BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_expires_at
ON local_mcp_bind_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_user_id
ON local_mcp_bind_sessions(user_id) WHERE user_id IS NOT NULL;
-- FK: user_id columns -> users(id) (nullable = legacy rows; ON DELETE SET NULL)
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_user_id'
) THEN
ALTER TABLE entries
ADD CONSTRAINT fk_entries_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_history_user_id'
) THEN
ALTER TABLE entries_history
ADD CONSTRAINT fk_entries_history_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_secrets_user_id'
) THEN
ALTER TABLE secrets
ADD CONSTRAINT fk_secrets_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_user_id'
) THEN
ALTER TABLE audit_log
ADD CONSTRAINT fk_audit_log_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
"#,
)
.execute(pool)
.await?;
migrate_schema(pool).await?;
restore_plaintext_api_keys(pool).await?;
tracing::debug!("migrations complete");
Ok(())
}
/// Idempotent schema migration: rename namespace→folder, kind→type in existing databases.
async fn migrate_schema(pool: &PgPool) -> Result<()> {
sqlx::raw_sql(
r#"
-- ── entries: rename namespace→folder, kind→type ──────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'namespace'
) THEN
ALTER TABLE entries RENAME COLUMN namespace TO folder;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'kind'
) THEN
ALTER TABLE entries RENAME COLUMN kind TO type;
END IF;
END $$;
-- ── audit_log: rename namespace→folder, kind→type ────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'namespace'
) THEN
ALTER TABLE audit_log RENAME COLUMN namespace TO folder;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'kind'
) THEN
ALTER TABLE audit_log RENAME COLUMN kind TO type;
END IF;
END $$;
-- ── entries_history: rename namespace→folder, kind→type ──────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'namespace'
) THEN
ALTER TABLE entries_history RENAME COLUMN namespace TO folder;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'kind'
) THEN
ALTER TABLE entries_history RENAME COLUMN kind TO type;
END IF;
END $$;
-- ── Set empty defaults for new folder/type columns ────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'folder'
) THEN
UPDATE entries SET folder = '' WHERE folder IS NULL;
ALTER TABLE entries ALTER COLUMN folder SET NOT NULL;
ALTER TABLE entries ALTER COLUMN folder SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'type'
) THEN
UPDATE entries SET type = '' WHERE type IS NULL;
ALTER TABLE entries ALTER COLUMN type SET NOT NULL;
ALTER TABLE entries ALTER COLUMN type SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'folder'
) THEN
UPDATE audit_log SET folder = '' WHERE folder IS NULL;
ALTER TABLE audit_log ALTER COLUMN folder SET NOT NULL;
ALTER TABLE audit_log ALTER COLUMN folder SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'type'
) THEN
UPDATE audit_log SET type = '' WHERE type IS NULL;
ALTER TABLE audit_log ALTER COLUMN type SET NOT NULL;
ALTER TABLE audit_log ALTER COLUMN type SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'folder'
) THEN
UPDATE entries_history SET folder = '' WHERE folder IS NULL;
ALTER TABLE entries_history ALTER COLUMN folder SET NOT NULL;
ALTER TABLE entries_history ALTER COLUMN folder SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'type'
) THEN
UPDATE entries_history SET type = '' WHERE type IS NULL;
ALTER TABLE entries_history ALTER COLUMN type SET NOT NULL;
ALTER TABLE entries_history ALTER COLUMN type SET DEFAULT '';
END IF;
END $$;
-- ── Rebuild unique indexes on entries: folder is now part of the key ────────
-- (user_id, folder, name) allows same name in different folders.
DROP INDEX IF EXISTS idx_entries_unique_legacy;
DROP INDEX IF EXISTS idx_entries_unique_user;
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
ON entries(folder, name)
WHERE user_id IS NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
ON entries(user_id, folder, name)
WHERE user_id IS NOT NULL AND deleted_at IS NULL;
-- ── Replace old namespace/kind indexes ────────────────────────────────────
DROP INDEX IF EXISTS idx_entries_namespace;
DROP INDEX IF EXISTS idx_entries_kind;
DROP INDEX IF EXISTS idx_audit_log_ns_kind;
DROP INDEX IF EXISTS idx_entries_history_ns_kind_name;
CREATE INDEX IF NOT EXISTS idx_entries_folder
ON entries(folder) WHERE folder <> '';
CREATE INDEX IF NOT EXISTS idx_entries_type
ON entries(type) WHERE type <> '';
CREATE INDEX IF NOT EXISTS idx_entries_deleted_at
ON entries(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type
ON audit_log(folder, type);
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
ON entries_history(folder, type, name, version DESC);
-- ── Drop legacy actor columns ─────────────────────────────────────────────
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
-- ── key_version: incremented on passphrase change to invalidate other sessions ──
ALTER TABLE users ADD COLUMN IF NOT EXISTS key_version BIGINT NOT NULL DEFAULT 0;
"#,
)
.execute(pool)
.await?;
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 folder: &'a str,
pub entry_type: &'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, folder, type, name, version, action, tags, metadata, user_id) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.bind(p.entry_id)
.bind(p.folder)
.bind(p.entry_type)
.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 secret_id: uuid::Uuid,
pub 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 \
(secret_id, name, encrypted, action) \
VALUES ($1, $2, $3, $4)",
)
.bind(p.secret_id)
.bind(p.name)
.bind(p.encrypted)
.bind(p.action)
.execute(&mut **tx)
.await?;
Ok(())
}
pub const ENTRY_HISTORY_SECRETS_KEY: &str = "__secrets_snapshot_v1";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EntrySecretSnapshot {
pub name: String,
#[serde(rename = "type")]
pub secret_type: String,
pub encrypted_hex: String,
}
pub async fn metadata_with_secret_snapshot(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
entry_id: uuid::Uuid,
metadata: &Value,
) -> Result<Value> {
#[derive(sqlx::FromRow)]
struct Row {
name: String,
#[sqlx(rename = "type")]
secret_type: String,
encrypted: Vec<u8>,
}
let rows: Vec<Row> = sqlx::query_as(
"SELECT s.name, s.type, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 \
ORDER BY s.name ASC",
)
.bind(entry_id)
.fetch_all(&mut **tx)
.await?;
let snapshots: Vec<EntrySecretSnapshot> = rows
.into_iter()
.map(|r| EntrySecretSnapshot {
name: r.name,
secret_type: r.secret_type,
encrypted_hex: ::hex::encode(r.encrypted),
})
.collect();
let mut merged = match metadata.clone() {
Value::Object(obj) => obj,
_ => Map::new(),
};
merged.insert(
ENTRY_HISTORY_SECRETS_KEY.to_string(),
serde_json::to_value(snapshots)?,
);
Ok(Value::Object(merged))
}
pub fn strip_secret_snapshot_from_metadata(metadata: &Value) -> Value {
let mut m = match metadata.clone() {
Value::Object(obj) => obj,
_ => return metadata.clone(),
};
m.remove(ENTRY_HISTORY_SECRETS_KEY);
Value::Object(m)
}
pub fn entry_secret_snapshot_from_metadata(metadata: &Value) -> Option<Vec<EntrySecretSnapshot>> {
let Value::Object(map) = metadata else {
return None;
};
let raw = map.get(ENTRY_HISTORY_SECRETS_KEY)?;
serde_json::from_value(raw.clone()).ok()
}
// ── DB helpers ────────────────────────────────────────────────────────────────

View File

@@ -1,172 +0,0 @@
use sqlx::error::DatabaseError;
/// Structured business errors for the secrets service.
///
/// These replace ad-hoc `anyhow` strings for expected failure modes,
/// allowing MCP and Web layers to map to appropriate protocol-level errors.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("A secret with the name '{secret_name}' already exists for this user")]
ConflictSecretName { secret_name: String },
#[error("An entry with folder='{folder}' and name='{name}' already exists")]
ConflictEntryName { folder: String, name: String },
#[error("Entry not found")]
NotFoundEntry,
#[error("User not found")]
NotFoundUser,
#[error("Secret not found")]
NotFoundSecret,
#[error("Authentication failed")]
AuthenticationFailed,
#[error("Unauthorized: insufficient permissions")]
Unauthorized,
#[error("Validation failed: {message}")]
Validation { message: String },
#[error("Concurrent modification detected")]
ConcurrentModification,
#[error("Decryption failed — the encryption key may be incorrect")]
DecryptionFailed,
#[error("Encryption key not set — user must set passphrase first")]
EncryptionKeyNotSet,
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
impl AppError {
/// Try to convert a sqlx database error into a structured `AppError`.
///
/// The caller should provide the context (which table was being written,
/// what values were being inserted) so we can produce a meaningful error.
pub fn from_db_error(err: sqlx::Error, ctx: DbErrorContext<'_>) -> Self {
if let sqlx::Error::Database(ref db_err) = err
&& db_err.code().as_deref() == Some("23505")
{
return Self::from_unique_violation(db_err.as_ref(), ctx);
}
AppError::Internal(err.into())
}
fn from_unique_violation(db_err: &dyn DatabaseError, ctx: DbErrorContext<'_>) -> Self {
let constraint = db_err.constraint();
match constraint {
Some("idx_secrets_unique_user_name") => AppError::ConflictSecretName {
secret_name: ctx.secret_name.unwrap_or("unknown").to_string(),
},
Some("idx_entries_unique_user") | Some("idx_entries_unique_legacy") => {
AppError::ConflictEntryName {
folder: ctx.folder.unwrap_or("").to_string(),
name: ctx.name.unwrap_or("unknown").to_string(),
}
}
_ => {
// Fall back to message-based detection for unnamed constraints
let msg = db_err.message();
if msg.contains("secrets") {
AppError::ConflictSecretName {
secret_name: ctx.secret_name.unwrap_or("unknown").to_string(),
}
} else {
AppError::ConflictEntryName {
folder: ctx.folder.unwrap_or("").to_string(),
name: ctx.name.unwrap_or("unknown").to_string(),
}
}
}
}
}
}
/// Context hints used when converting a database error to `AppError`.
#[derive(Debug, Default, Clone, Copy)]
pub struct DbErrorContext<'a> {
pub secret_name: Option<&'a str>,
pub folder: Option<&'a str>,
pub name: Option<&'a str>,
}
impl<'a> DbErrorContext<'a> {
pub fn secret_name(name: &'a str) -> Self {
Self {
secret_name: Some(name),
..Default::default()
}
}
pub fn entry(folder: &'a str, name: &'a str) -> Self {
Self {
folder: Some(folder),
name: Some(name),
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn app_error_display_messages() {
let err = AppError::ConflictSecretName {
secret_name: "token".to_string(),
};
assert!(err.to_string().contains("token"));
let err = AppError::ConflictEntryName {
folder: "refining".to_string(),
name: "gitea".to_string(),
};
assert!(err.to_string().contains("refining"));
assert!(err.to_string().contains("gitea"));
let err = AppError::NotFoundEntry;
assert_eq!(err.to_string(), "Entry not found");
let err = AppError::NotFoundUser;
assert_eq!(err.to_string(), "User not found");
let err = AppError::NotFoundSecret;
assert_eq!(err.to_string(), "Secret not found");
let err = AppError::AuthenticationFailed;
assert_eq!(err.to_string(), "Authentication failed");
let err = AppError::Unauthorized;
assert!(err.to_string().contains("Unauthorized"));
let err = AppError::Validation {
message: "too long".to_string(),
};
assert!(err.to_string().contains("too long"));
let err = AppError::ConcurrentModification;
assert!(err.to_string().contains("Concurrent modification"));
let err = AppError::EncryptionKeyNotSet;
assert!(err.to_string().contains("Encryption key not set"));
}
#[test]
fn db_error_context_helpers() {
let ctx = DbErrorContext::secret_name("my_key");
assert_eq!(ctx.secret_name, Some("my_key"));
assert!(ctx.folder.is_none());
let ctx = DbErrorContext::entry("prod", "db-creds");
assert_eq!(ctx.folder, Some("prod"));
assert_eq!(ctx.name, Some("db-creds"));
assert!(ctx.secret_name.is_none());
}
}

View File

@@ -1,8 +0,0 @@
pub mod audit;
pub mod config;
pub mod crypto;
pub mod db;
pub mod error;
pub mod models;
pub mod service;
pub mod taxonomy;

View File

@@ -1,357 +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, account, person, …).
/// 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 folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub notes: String,
pub tags: Vec<String>,
pub metadata: Value,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
/// A single encrypted field belonging to an Entry.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecretField {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub name: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub secret_type: 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 folder: String,
#[sqlx(rename = "type")]
pub entry_type: String,
pub tags: Vec<String>,
pub metadata: Value,
pub notes: String,
pub name: String,
}
/// Entry row including `name` (used for id-scoped web / service updates).
#[derive(Debug, sqlx::FromRow)]
pub struct EntryWriteRow {
pub id: Uuid,
pub version: i64,
pub folder: String,
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub tags: Vec<String>,
pub metadata: Value,
pub notes: String,
pub deleted_at: Option<DateTime<Utc>>,
}
impl From<&EntryWriteRow> for EntryRow {
fn from(r: &EntryWriteRow) -> Self {
EntryRow {
id: r.id,
version: r.version,
folder: r.folder.clone(),
entry_type: r.entry_type.clone(),
tags: r.tags.clone(),
metadata: r.metadata.clone(),
notes: r.notes.clone(),
name: r.name.clone(),
}
}
}
/// Minimal secret field row fetched before snapshots or cascade deletes.
#[derive(Debug, sqlx::FromRow)]
pub struct SecretFieldRow {
pub id: Uuid,
pub 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 name: String,
#[serde(default)]
pub folder: String,
#[serde(default, rename = "type")]
pub entry_type: String,
#[serde(default)]
pub notes: 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>>,
/// Per-secret types (`text`, `password`, `key`, …). Omitted in legacy exports; importers default to `"text"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret_types: Option<BTreeMap<String, String>>,
}
// ── 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>,
/// Incremented each time the passphrase is changed; used to invalidate sessions on other devices.
pub key_version: i64,
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 folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: 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)
}
}
}
#[cfg(test)]
mod export_entry_tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn export_entry_roundtrip_includes_secret_types() {
let mut secrets = BTreeMap::new();
secrets.insert("k".to_string(), serde_json::json!("v"));
let mut types = BTreeMap::new();
types.insert("k".to_string(), "password".to_string());
let e = ExportEntry {
name: "n".to_string(),
folder: "f".to_string(),
entry_type: "t".to_string(),
notes: "".to_string(),
tags: vec![],
metadata: serde_json::json!({}),
secrets: Some(secrets),
secret_types: Some(types),
};
let json = serde_json::to_string(&e).unwrap();
let back: ExportEntry = serde_json::from_str(&json).unwrap();
assert_eq!(
back.secret_types
.as_ref()
.unwrap()
.get("k")
.map(String::as_str),
Some("password")
);
}
#[test]
fn export_entry_legacy_json_without_secret_types_deserializes() {
let json = r#"{"name":"a","folder":"","type":"","notes":"","tags":[],"metadata":{},"secrets":{"x":"y"}}"#;
let e: ExportEntry = serde_json::from_str(json).unwrap();
assert!(e.secret_types.is_none());
}
}

View File

@@ -1,813 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use std::collections::{BTreeSet, HashSet};
use std::fs;
use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::error::{AppError, DbErrorContext};
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 entry_id: Uuid,
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
pub tags: Vec<String>,
pub meta_keys: Vec<String>,
pub secret_keys: Vec<String>,
}
pub struct AddParams<'a> {
pub name: &'a str,
pub folder: &'a str,
pub entry_type: &'a str,
pub notes: &'a str,
pub tags: &'a [String],
pub meta_entries: &'a [String],
pub secret_entries: &'a [String],
pub secret_types: &'a std::collections::HashMap<String, String>,
pub link_secret_names: &'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> {
if params.folder.chars().count() > 128 {
anyhow::bail!("folder must be at most 128 characters");
}
if params.name.chars().count() > 256 {
anyhow::bail!("name must be at most 256 characters");
}
if params.entry_type.trim().chars().count() > 64 {
anyhow::bail!("type must be at most 64 characters");
}
let Value::Object(metadata_map) = build_json(params.meta_entries)? else {
unreachable!("build_json always returns a JSON object");
};
let entry_type = params.entry_type.trim();
let metadata = Value::Object(metadata_map);
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 flat_fields = flatten_json_fields("", &secret_json);
let new_secret_names: BTreeSet<String> =
flat_fields.iter().map(|(name, _)| name.clone()).collect();
let link_secret_names =
validate_link_secret_names(params.link_secret_names, &new_secret_names)?;
let mut tx = pool.begin().await?;
// Fetch existing entry by (user_id, folder, name) — the natural unique key
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL",
)
.bind(uid)
.bind(params.folder)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2 AND deleted_at IS NULL",
)
.bind(params.folder)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
};
if let Some(ref ex) = existing {
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, ex.id, &ex.metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
ex.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: ex.id,
user_id: params.user_id,
folder: params.folder,
entry_type,
name: params.name,
version: ex.version,
action: "add",
tags: &ex.tags,
metadata: &history_metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
}
}
// Upsert the entry row. On conflict (existing entry with same user_id+folder+name),
// the entry columns are replaced wholesale. The old secret associations are torn down
// below within the same transaction, so the whole operation is atomic: if any step
// after this point fails, the transaction rolls back and the entry reverts to its
// pre-upsert state (including the version bump that happened in the DO UPDATE clause).
let entry_id: Uuid = if let Some(uid) = params.user_id {
sqlx::query_scalar(
r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW())
ON CONFLICT (user_id, folder, name) WHERE user_id IS NOT NULL
DO UPDATE SET
folder = EXCLUDED.folder,
type = EXCLUDED.type,
notes = EXCLUDED.notes,
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id"#,
)
.bind(uid)
.bind(params.folder)
.bind(entry_type)
.bind(params.name)
.bind(params.notes)
.bind(params.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
r#"INSERT INTO entries (folder, type, name, notes, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
ON CONFLICT (folder, name) WHERE user_id IS NULL
DO UPDATE SET
folder = EXCLUDED.folder,
type = EXCLUDED.type,
notes = EXCLUDED.notes,
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id"#,
)
.bind(params.folder)
.bind(entry_type)
.bind(params.name)
.bind(params.notes)
.bind(params.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?
};
let current_entry_version: i64 =
sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
.bind(entry_id)
.fetch_one(&mut *tx)
.await?;
if existing.is_some() {
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
name: String,
encrypted: Vec<u8>,
}
let existing_fields: Vec<ExistingField> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.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 {
secret_id: f.id,
name: &f.name,
encrypted: &f.encrypted,
action: "add",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
}
let orphan_candidates: Vec<Uuid> = existing_fields.iter().map(|f| f.id).collect();
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1")
.bind(entry_id)
.execute(&mut *tx)
.await?;
if !orphan_candidates.is_empty() {
sqlx::query(
"DELETE FROM secrets s \
WHERE s.id = ANY($1) \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(&orphan_candidates)
.execute(&mut *tx)
.await?;
}
}
for (field_name, field_value) in &flat_fields {
let encrypted = crypto::encrypt_json(master_key, field_value)?;
let secret_type = params
.secret_types
.get(field_name)
.map(|s| s.as_str())
.unwrap_or("text");
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(params.user_id)
.bind(field_name)
.bind(secret_type)
.bind(&encrypted)
.fetch_one(&mut *tx)
.await
.map_err(|e| AppError::from_db_error(e, DbErrorContext::secret_name(field_name)))?;
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(entry_id)
.bind(secret_id)
.execute(&mut *tx)
.await?;
}
for link_name in &link_secret_names {
let secret_ids: Vec<Uuid> = if let Some(uid) = params.user_id {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2")
.bind(uid)
.bind(link_name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1")
.bind(link_name)
.fetch_all(&mut *tx)
.await?
};
match secret_ids.len() {
0 => anyhow::bail!("Not found: secret named '{}'", link_name),
1 => {
sqlx::query(
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(entry_id)
.bind(secret_ids[0])
.execute(&mut *tx)
.await?;
}
n => anyhow::bail!(
"Ambiguous: {} secrets named '{}' found. Please deduplicate names first.",
n,
link_name
),
}
}
if existing.is_none() {
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, entry_id, &metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id,
user_id: params.user_id,
folder: params.folder,
entry_type,
name: params.name,
version: current_entry_version,
action: "create",
tags: params.tags,
metadata: &history_metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history on create");
}
}
crate::audit::log_tx(
&mut tx,
params.user_id,
"add",
params.folder,
entry_type,
params.name,
serde_json::json!({
"tags": params.tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
}),
)
.await;
tx.commit().await?;
Ok(AddResult {
entry_id,
name: params.name.to_string(),
folder: params.folder.to_string(),
entry_type: entry_type.to_string(),
tags: params.tags.to_vec(),
meta_keys,
secret_keys,
})
}
fn validate_link_secret_names(
link_secret_names: &[String],
new_secret_names: &BTreeSet<String>,
) -> Result<Vec<String>> {
let mut deduped = Vec::new();
let mut seen = HashSet::new();
for raw in link_secret_names {
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!("link_secret_names contains an empty name");
}
if new_secret_names.contains(trimmed) {
anyhow::bail!(
"Conflict: secret '{}' is provided both in secrets/secrets_obj and link_secret_names",
trimmed
);
}
if seen.insert(trimmed.to_string()) {
deduped.push(trimmed.to_string());
}
}
Ok(deduped)
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::PgPool;
use std::collections::BTreeSet;
#[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");
}
#[test]
fn validate_link_secret_names_conflict_with_new_secret() {
let mut new_names = BTreeSet::new();
new_names.insert("password".to_string());
let err = validate_link_secret_names(&[String::from("password")], &new_names)
.expect_err("must fail on overlap");
assert!(
err.to_string()
.contains("provided both in secrets/secrets_obj and link_secret_names")
);
}
#[test]
fn validate_link_secret_names_dedup_and_trim() {
let names = vec![
" shared_key ".to_string(),
"shared_key".to_string(),
"runner_token".to_string(),
];
let deduped = validate_link_secret_names(&names, &BTreeSet::new()).unwrap();
assert_eq!(deduped, vec!["shared_key", "runner_token"]);
}
async fn maybe_test_pool() -> Option<PgPool> {
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
eprintln!("skip add linkage tests: SECRETS_DATABASE_URL is not set");
return None;
};
let Ok(pool) = PgPool::connect(&url).await else {
eprintln!("skip add linkage tests: cannot connect to database");
return None;
};
if let Err(e) = crate::db::migrate(&pool).await {
eprintln!("skip add linkage tests: migrate failed: {e}");
return None;
}
Some(pool)
}
async fn cleanup_test_rows(pool: &PgPool, marker: &str) -> Result<()> {
sqlx::query(
"DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)",
)
.bind(format!("%{marker}%"))
.execute(pool)
.await?;
sqlx::query(
"DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)",
)
.bind(format!("%{marker}%"))
.execute(pool)
.await?;
Ok(())
}
#[tokio::test]
async fn add_links_existing_secret_by_unique_name() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("link_unique_{}", &suffix[..8]);
let secret_name = format!("{}_secret", marker);
let entry_name = format!("{}_entry", marker);
cleanup_test_rows(&pool, &marker).await?;
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2) RETURNING id",
)
.bind(&secret_name)
.bind(vec![1_u8, 2, 3])
.fetch_one(&pool)
.await?;
run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[],
secret_types: &Default::default(),
link_secret_names: std::slice::from_ref(&secret_name),
user_id: None,
},
&[0_u8; 32],
)
.await?;
let linked: bool = sqlx::query_scalar(
"SELECT EXISTS( \
SELECT 1 FROM entry_secrets es \
JOIN entries e ON e.id = es.entry_id \
WHERE e.user_id IS NULL AND e.name = $1 AND es.secret_id = $2 \
)",
)
.bind(&entry_name)
.bind(secret_id)
.fetch_one(&pool)
.await?;
assert!(linked);
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
#[tokio::test]
async fn add_link_secret_name_not_found_fails() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("link_missing_{}", &suffix[..8]);
let secret_name = format!("{}_secret", marker);
let entry_name = format!("{}_entry", marker);
cleanup_test_rows(&pool, &marker).await?;
let err = run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[],
secret_types: &Default::default(),
link_secret_names: std::slice::from_ref(&secret_name),
user_id: None,
},
&[0_u8; 32],
)
.await
.expect_err("must fail when linked secret is not found");
assert!(err.to_string().contains("Not found: secret named"));
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
#[tokio::test]
async fn add_link_secret_name_ambiguous_fails() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("link_amb_{}", &suffix[..8]);
let secret_name = format!("{}_dup_secret", marker);
let entry_name = format!("{}_entry", marker);
cleanup_test_rows(&pool, &marker).await?;
sqlx::query(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
)
.bind(&secret_name)
.bind(vec![1_u8])
.execute(&pool)
.await?;
sqlx::query(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
)
.bind(&secret_name)
.bind(vec![2_u8])
.execute(&pool)
.await?;
let err = run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[],
secret_types: &Default::default(),
link_secret_names: std::slice::from_ref(&secret_name),
user_id: None,
},
&[0_u8; 32],
)
.await
.expect_err("must fail on ambiguous linked secret name");
assert!(err.to_string().contains("Ambiguous:"));
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
#[tokio::test]
async fn add_duplicate_secret_name_returns_conflict_error() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("dup_secret_{}", &suffix[..8]);
let entry_name = format!("{}_entry", marker);
let secret_name = "shared_token";
cleanup_test_rows(&pool, &marker).await?;
// First add succeeds
run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[format!("{}=value1", secret_name)],
secret_types: &Default::default(),
link_secret_names: &[],
user_id: None,
},
&[0_u8; 32],
)
.await?;
// Second add with same secret name under same user_id should fail with ConflictSecretName
let entry_name2 = format!("{}_entry2", marker);
let err = run(
&pool,
AddParams {
name: &entry_name2,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[format!("{}=value2", secret_name)],
secret_types: &Default::default(),
link_secret_names: &[],
user_id: None,
},
&[0_u8; 32],
)
.await
.expect_err("must fail on duplicate secret name");
let app_err = err
.downcast_ref::<crate::error::AppError>()
.expect("error should be AppError");
assert!(
matches!(app_err, crate::error::AppError::ConflictSecretName { .. }),
"expected ConflictSecretName, got: {}",
app_err
);
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
}

View File

@@ -1,95 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::AppError;
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);
format!("{}{}", KEY_PREFIX, ::hex::encode(bytes))
}
/// Return the user's existing API key, or generate and store a new one if NULL.
/// Uses a transaction with atomic update to prevent TOCTOU race conditions.
pub async fn ensure_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
let mut tx = pool.begin().await?;
// Lock the row and check existing key
let existing: (Option<String>,) =
sqlx::query_as("SELECT api_key FROM users WHERE id = $1 FOR UPDATE")
.bind(user_id)
.fetch_optional(&mut *tx)
.await?
.ok_or(AppError::NotFoundUser)?;
if let Some(key) = existing.0 {
tx.commit().await?;
return Ok(key);
}
// Generate and store new key atomically
let new_key = generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user_id)
.execute(&mut *tx)
.await?;
tx.commit().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();
let res = sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user_id)
.execute(pool)
.await?;
if res.rows_affected() == 0 {
return Err(AppError::NotFoundUser.into());
}
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))
}
#[cfg(test)]
mod tests {
use sqlx::PgPool;
use super::regenerate_api_key;
use crate::error::AppError;
#[tokio::test]
async fn regenerate_api_key_unknown_user_returns_not_found() {
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
return;
};
let Ok(pool) = PgPool::connect(&url).await else {
return;
};
let id = uuid::Uuid::new_v4();
let err = regenerate_api_key(&pool, id)
.await
.err()
.expect("expected error");
assert!(matches!(
err.downcast_ref::<AppError>(),
Some(AppError::NotFoundUser)
));
}
}

View File

@@ -1,39 +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,
offset: i64,
) -> Result<Vec<AuditLogEntry>> {
let limit = limit.clamp(1, 200);
let offset = offset.max(0);
let rows = sqlx::query_as(
"SELECT id, user_id, action, folder, type, name, detail, created_at \
FROM audit_log \
WHERE user_id = $1 \
ORDER BY created_at DESC, id DESC \
LIMIT $2 OFFSET $3",
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn count_for_user(pool: &PgPool, user_id: Uuid) -> Result<i64> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*)::bigint FROM audit_log WHERE user_id = $1")
.bind(user_id)
.fetch_one(pool)
.await?;
Ok(count)
}

View File

@@ -1,823 +0,0 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::error::AppError;
use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow};
use crate::service::util::user_scope_condition;
#[derive(Debug, serde::Serialize)]
pub struct DeletedEntry {
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
}
#[derive(Debug, serde::Serialize)]
pub struct DeleteResult {
pub deleted: Vec<DeletedEntry>,
pub dry_run: bool,
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct TrashEntry {
pub id: Uuid,
pub name: String,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub deleted_at: chrono::DateTime<chrono::Utc>,
}
pub struct DeleteParams<'a> {
/// If set, delete a single entry by name.
pub name: Option<&'a str>,
/// Folder filter for bulk delete.
pub folder: Option<&'a str>,
/// Type filter for bulk delete.
pub entry_type: Option<&'a str>,
pub dry_run: bool,
pub user_id: Option<Uuid>,
}
/// Maximum number of entries that can be deleted in a single bulk operation.
/// Prevents accidental mass deletion when filters are too broad.
pub const MAX_BULK_DELETE: usize = 1000;
pub async fn list_deleted_entries(
pool: &PgPool,
user_id: Uuid,
limit: u32,
offset: u32,
) -> Result<Vec<TrashEntry>> {
sqlx::query_as(
"SELECT id, name, folder, type, deleted_at FROM entries \
WHERE user_id = $1 AND deleted_at IS NOT NULL \
ORDER BY deleted_at DESC, name ASC LIMIT $2 OFFSET $3",
)
.bind(user_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn count_deleted_entries(pool: &PgPool, user_id: Uuid) -> Result<i64> {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*)::bigint FROM entries WHERE user_id = $1 AND deleted_at IS NOT NULL",
)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn restore_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
return Err(AppError::NotFoundEntry.into());
}
};
let conflict_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL AND id <> $4)",
)
.bind(user_id)
.bind(&row.folder)
.bind(&row.name)
.bind(row.id)
.fetch_one(&mut *tx)
.await?;
if conflict_exists {
tx.rollback().await?;
return Err(AppError::ConflictEntryName {
folder: row.folder,
name: row.name,
}
.into());
}
sqlx::query("UPDATE entries SET deleted_at = NULL, updated_at = NOW() WHERE id = $1")
.bind(row.id)
.execute(&mut *tx)
.await?;
crate::audit::log_tx(
&mut tx,
Some(user_id),
"restore",
&row.folder,
&row.entry_type,
&row.name,
json!({ "entry_id": row.id }),
)
.await;
tx.commit().await?;
Ok(())
}
pub async fn purge_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
return Err(AppError::NotFoundEntry.into());
}
};
purge_entry_record(&mut tx, row.id).await?;
crate::audit::log_tx(
&mut tx,
Some(user_id),
"purge",
&row.folder,
&row.entry_type,
&row.name,
json!({ "entry_id": row.id }),
)
.await;
tx.commit().await?;
Ok(())
}
pub async fn purge_expired_deleted_entries(pool: &PgPool) -> Result<u64> {
#[derive(sqlx::FromRow)]
struct ExpiredRow {
id: Uuid,
}
let mut tx = pool.begin().await?;
let rows: Vec<ExpiredRow> = sqlx::query_as(
"SELECT id FROM entries \
WHERE deleted_at IS NOT NULL \
AND deleted_at < NOW() - INTERVAL '3 months' \
FOR UPDATE",
)
.fetch_all(&mut *tx)
.await?;
for row in &rows {
purge_entry_record(&mut tx, row.id).await?;
}
tx.commit().await?;
Ok(rows.len() as u64)
}
/// Delete a single entry by id (multi-tenant: `user_id` must match).
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
anyhow::bail!("Entry not found");
}
};
let folder = row.folder.clone();
let entry_type = row.entry_type.clone();
let name = row.name.clone();
let entry_row: EntryRow = (&row).into();
snapshot_and_soft_delete(
&mut tx,
&folder,
&entry_type,
&name,
&entry_row,
Some(user_id),
)
.await?;
crate::audit::log_tx(
&mut tx,
Some(user_id),
"delete",
&folder,
&entry_type,
&name,
json!({ "source": "web", "entry_id": entry_id }),
)
.await;
tx.commit().await?;
Ok(DeleteResult {
deleted: vec![DeletedEntry {
name,
folder,
entry_type,
}],
dry_run: false,
})
}
pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult> {
match params.name {
Some(name) => delete_one(pool, name, params.folder, params.dry_run, params.user_id).await,
None => {
if params.folder.is_none() && params.entry_type.is_none() {
anyhow::bail!(
"Bulk delete requires at least one of: name, folder, or type filter."
);
}
delete_bulk(
pool,
params.folder,
params.entry_type,
params.dry_run,
params.user_id,
)
.await
}
}
}
async fn delete_one(
pool: &PgPool,
name: &str,
folder: Option<&str>,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
if dry_run {
// Dry-run uses the same disambiguation logic as actual delete:
// - 0 matches → nothing to delete
// - 1 match → show what would be deleted (with correct folder/type)
// - 2+ matches → disambiguation error (same as non-dry-run)
#[derive(sqlx::FromRow)]
struct DryRunRow {
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
}
let mut idx = 1i32;
let user_cond = user_scope_condition(user_id, &mut idx);
let mut conditions = vec![user_cond];
if folder.is_some() {
conditions.push(format!("folder = ${}", idx));
idx += 1;
}
conditions.push(format!("name = ${}", idx));
let sql = format!(
"SELECT folder, type FROM entries WHERE {} AND deleted_at IS NULL",
conditions.join(" AND ")
);
let mut q = sqlx::query_as::<_, DryRunRow>(&sql);
if let Some(uid) = user_id {
q = q.bind(uid);
}
if let Some(f) = folder {
q = q.bind(f);
}
q = q.bind(name);
let rows = q.fetch_all(pool).await?;
return match rows.len() {
0 => Ok(DeleteResult {
deleted: vec![],
dry_run: true,
}),
1 => {
let row = rows
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?;
Ok(DeleteResult {
deleted: vec![DeletedEntry {
name: name.to_string(),
folder: row.folder,
entry_type: row.entry_type,
}],
dry_run: true,
})
}
_ => {
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
name,
folders.join(", ")
)
}
};
}
let mut tx = pool.begin().await?;
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
let mut idx = 1i32;
let user_cond = user_scope_condition(user_id, &mut idx);
let mut conditions = vec![user_cond];
if folder.is_some() {
conditions.push(format!("folder = ${}", idx));
idx += 1;
}
conditions.push(format!("name = ${}", idx));
let sql = format!(
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
WHERE {} AND deleted_at IS NULL FOR UPDATE",
conditions.join(" AND ")
);
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
if let Some(uid) = user_id {
q = q.bind(uid);
}
if let Some(f) = folder {
q = q.bind(f);
}
q = q.bind(name);
let rows = q.fetch_all(&mut *tx).await?;
let row = match rows.len() {
0 => {
tx.rollback().await?;
return Ok(DeleteResult {
deleted: vec![],
dry_run: false,
});
}
1 => rows
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?,
_ => {
tx.rollback().await?;
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
name,
folders.join(", ")
)
}
};
let folder = row.folder.clone();
let entry_type = row.entry_type.clone();
snapshot_and_soft_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
crate::audit::log_tx(
&mut tx,
user_id,
"delete",
&folder,
&entry_type,
name,
json!({}),
)
.await;
tx.commit().await?;
Ok(DeleteResult {
deleted: vec![DeletedEntry {
name: name.to_string(),
folder,
entry_type,
}],
dry_run: false,
})
}
async fn delete_bulk(
pool: &PgPool,
folder: Option<&str>,
entry_type: Option<&str>,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
#[derive(Debug, sqlx::FromRow)]
struct FullEntryRow {
id: Uuid,
version: i64,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
metadata: serde_json::Value,
tags: Vec<String>,
notes: String,
}
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if user_id.is_some() {
conditions.push(format!("user_id = ${}", idx));
idx += 1;
} else {
conditions.push("user_id IS NULL".to_string());
}
if folder.is_some() {
conditions.push(format!("folder = ${}", idx));
idx += 1;
}
if entry_type.is_some() {
conditions.push(format!("type = ${}", idx));
idx += 1;
}
let where_clause = format!("WHERE {}", conditions.join(" AND "));
let _ = idx; // used only for placeholder numbering in conditions
if dry_run {
let sql = format!(
"SELECT id, version, folder, type, name, metadata, tags, notes \
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name"
);
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
if let Some(uid) = user_id {
q = q.bind(uid);
}
if let Some(f) = folder {
q = q.bind(f);
}
if let Some(t) = entry_type {
q = q.bind(t);
}
let rows = q.fetch_all(pool).await?;
let deleted = rows
.iter()
.map(|r| DeletedEntry {
name: r.name.clone(),
folder: r.folder.clone(),
entry_type: r.entry_type.clone(),
})
.collect();
return Ok(DeleteResult {
deleted,
dry_run: true,
});
}
let mut tx = pool.begin().await?;
let sql = format!(
"SELECT id, version, folder, type, name, metadata, tags, notes \
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name FOR UPDATE"
);
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
if let Some(uid) = user_id {
q = q.bind(uid);
}
if let Some(f) = folder {
q = q.bind(f);
}
if let Some(t) = entry_type {
q = q.bind(t);
}
let rows = q.fetch_all(&mut *tx).await?;
if rows.len() > MAX_BULK_DELETE {
tx.rollback().await?;
anyhow::bail!(
"Bulk delete would affect {} entries (limit: {}). \
Narrow your filters or delete entries individually.",
rows.len(),
MAX_BULK_DELETE,
);
}
let mut deleted = Vec::with_capacity(rows.len());
for row in &rows {
let entry_row: EntryRow = EntryRow {
id: row.id,
version: row.version,
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
tags: row.tags.clone(),
metadata: row.metadata.clone(),
notes: row.notes.clone(),
name: row.name.clone(),
};
snapshot_and_soft_delete(
&mut tx,
&row.folder,
&row.entry_type,
&row.name,
&entry_row,
user_id,
)
.await?;
crate::audit::log_tx(
&mut tx,
user_id,
"delete",
&row.folder,
&row.entry_type,
&row.name,
json!({"bulk": true}),
)
.await;
deleted.push(DeletedEntry {
name: row.name.clone(),
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
});
}
tx.commit().await?;
Ok(DeleteResult {
deleted,
dry_run: false,
})
}
async fn snapshot_and_soft_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
folder: &str,
entry_type: &str,
name: &str,
row: &EntryRow,
user_id: Option<Uuid>,
) -> Result<()> {
let history_metadata = match db::metadata_with_secret_snapshot(tx, row.id, &row.metadata).await
{
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
row.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id,
folder,
entry_type,
name,
version: row.version,
action: "delete",
tags: &row.tags,
metadata: &history_metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before delete");
}
let fields: Vec<SecretFieldRow> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.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 {
secret_id: f.id,
name: &f.name,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret history before delete");
}
}
sqlx::query("UPDATE entries SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1")
.bind(row.id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn purge_entry_record(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
entry_id: Uuid,
) -> Result<()> {
let fields: Vec<SecretFieldRow> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(entry_id)
.fetch_all(&mut **tx)
.await?;
sqlx::query("DELETE FROM entries WHERE id = $1")
.bind(entry_id)
.execute(&mut **tx)
.await?;
let secret_ids: Vec<Uuid> = fields.iter().map(|f| f.id).collect();
if !secret_ids.is_empty() {
sqlx::query(
"DELETE FROM secrets s \
WHERE s.id = ANY($1) \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(&secret_ids)
.execute(&mut **tx)
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::PgPool;
async fn maybe_test_pool() -> Option<PgPool> {
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
eprintln!("skip delete tests: SECRETS_DATABASE_URL is not set");
return None;
};
let Ok(pool) = PgPool::connect(&url).await else {
eprintln!("skip delete tests: cannot connect to database");
return None;
};
if let Err(e) = crate::db::migrate(&pool).await {
eprintln!("skip delete tests: migrate failed: {e}");
return None;
}
Some(pool)
}
async fn cleanup_single_user_rows(pool: &PgPool, marker: &str) -> Result<()> {
sqlx::query(
"DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)",
)
.bind(format!("%{marker}%"))
.execute(pool)
.await?;
sqlx::query(
"DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)",
)
.bind(format!("%{marker}%"))
.execute(pool)
.await?;
Ok(())
}
#[tokio::test]
async fn delete_dry_run_reports_matching_entry_without_writes() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("delete_dry_{}", &suffix[..8]);
let entry_name = format!("{}_entry", marker);
cleanup_single_user_rows(&pool, &marker).await?;
sqlx::query(
"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata) \
VALUES (NULL, $1, 'service', $2, '', '{}', '{}')",
)
.bind(&marker)
.bind(&entry_name)
.execute(&pool)
.await?;
let result = run(
&pool,
DeleteParams {
name: Some(&entry_name),
folder: Some(&marker),
entry_type: None,
dry_run: true,
user_id: None,
},
)
.await?;
assert!(result.dry_run);
assert_eq!(result.deleted.len(), 1);
assert_eq!(result.deleted[0].name, entry_name);
let still_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2)",
)
.bind(&marker)
.bind(&entry_name)
.fetch_one(&pool)
.await?;
assert!(still_exists);
cleanup_single_user_rows(&pool, &marker).await?;
Ok(())
}
#[tokio::test]
async fn delete_by_id_removes_entry_and_orphan_secret() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("delete_id_{}", &suffix[..8]);
let user_id = Uuid::from_u128(rand::random());
let entry_name = format!("{}_entry", marker);
let secret_name = format!("{}_secret", marker);
sqlx::query("DELETE FROM entries WHERE user_id = $1 AND folder = $2")
.bind(user_id)
.bind(&marker)
.execute(&pool)
.await?;
sqlx::query("DELETE FROM secrets WHERE user_id = $1 AND name = $2")
.bind(user_id)
.bind(&secret_name)
.execute(&pool)
.await?;
let entry_id: Uuid = sqlx::query_scalar(
"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata) \
VALUES ($1, $2, 'service', $3, '', '{}', '{}') RETURNING id",
)
.bind(user_id)
.bind(&marker)
.bind(&entry_name)
.fetch_one(&pool)
.await?;
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, 'text', $3) RETURNING id",
)
.bind(user_id)
.bind(&secret_name)
.bind(vec![1_u8, 2, 3])
.fetch_one(&pool)
.await?;
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(entry_id)
.bind(secret_id)
.execute(&pool)
.await?;
let result = delete_by_id(&pool, entry_id, user_id).await?;
assert!(!result.dry_run);
assert_eq!(result.deleted.len(), 1);
assert_eq!(result.deleted[0].name, entry_name);
let entry_exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM entries WHERE id = $1)")
.bind(entry_id)
.fetch_one(&pool)
.await?;
let secret_exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM secrets WHERE id = $1)")
.bind(secret_id)
.fetch_one(&pool)
.await?;
assert!(!entry_exists);
assert!(!secret_exists);
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::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,
folder: Option<&str>,
entry_type: 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, folder, entry_type, name, tags, None, None, user_id).await?;
if entries.is_empty() {
return Ok(HashMap::new());
}
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let mut combined: HashMap<String, String> = HashMap::new();
for entry in &entries {
let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
let effective_prefix = env_prefix(entry, prefix);
let fields: Vec<_> = if only_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| only_fields.contains(&f.name))
.collect()
};
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
let seg = secret_name_to_env_segment(&f.name);
let key = format!("{}_{}", effective_prefix, seg);
if let Some(_old) = combined.insert(key.clone(), json_to_env_string(&decrypted)) {
anyhow::bail!(
"environment variable name collision after normalization: '{}' (secret '{}')",
key,
f.name
);
}
}
}
Ok(combined)
}
/// Map a secret field name to an env key segment: `.` → `__`, `-` → `_`, then uppercase.
/// Avoids collisions between e.g. `db.password` and `db_password`.
fn secret_name_to_env_segment(name: &str) -> String {
name.replace('.', "__").replace('-', "_").to_uppercase()
}
fn env_prefix(entry: &crate::models::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(),
}
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::{env_prefix, secret_name_to_env_segment};
use crate::models::Entry;
#[test]
fn secret_name_env_segment_disambiguates_dot_from_underscore() {
assert_eq!(secret_name_to_env_segment("db.password"), "DB__PASSWORD");
assert_eq!(secret_name_to_env_segment("db_password"), "DB_PASSWORD");
assert_eq!(secret_name_to_env_segment("api-key"), "API_KEY");
}
#[test]
fn env_prefix_with_and_without_prefix() {
let entry = Entry {
id: uuid::Uuid::new_v4(),
user_id: None,
folder: "test".into(),
entry_type: "server".into(),
name: "my-server".into(),
notes: String::new(),
tags: vec![],
metadata: Value::Null,
version: 1,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
deleted_at: None,
};
assert_eq!(env_prefix(&entry, ""), "MY_SERVER");
assert_eq!(env_prefix(&entry, "ALIYUN"), "ALIYUN_MY_SERVER");
assert_eq!(env_prefix(&entry, "aliyun_"), "ALIYUN_MY_SERVER");
}
}

View File

@@ -1,144 +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 folder: Option<&'a str>,
pub entry_type: 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.folder,
params.entry_type,
params.name,
params.tags,
params.query,
None,
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, secret_types) = if params.no_secrets {
(None, None)
} else {
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
if fields.is_empty() {
(Some(BTreeMap::new()), Some(BTreeMap::new()))
} else {
let mk = master_key
.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
let mut map = BTreeMap::new();
let mut type_map = BTreeMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
map.insert(f.name.clone(), decrypted);
type_map.insert(f.name.clone(), f.secret_type.clone());
}
(Some(map), Some(type_map))
}
};
export_entries.push(ExportEntry {
name: entry.name.clone(),
folder: entry.folder.clone(),
entry_type: entry.entry_type.clone(),
notes: entry.notes.clone(),
tags: entry.tags.clone(),
metadata: entry.metadata.clone(),
secrets,
secret_types,
});
}
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,105 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
use crate::crypto;
use crate::error::AppError;
use crate::service::search::{fetch_secrets_for_entries, resolve_entry, resolve_entry_by_id};
/// Decrypt a single named field from an entry.
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn get_secret_field(
pool: &PgPool,
name: &str,
folder: Option<&str>,
field_name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<Value> {
let entry = resolve_entry(pool, name, folder, user_id).await?;
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.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.
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn get_all_secrets(
pool: &PgPool,
name: &str,
folder: Option<&str>,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, Value>> {
let entry = resolve_entry(pool, name, folder, user_id).await?;
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.name.clone(), decrypted);
}
Ok(map)
}
/// Decrypt a single named field from an entry, located by its UUID.
pub async fn get_secret_field_by_id(
pool: &PgPool,
entry_id: Uuid,
field_name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<Value> {
resolve_entry_by_id(pool, entry_id, user_id)
.await
.map_err(|_| anyhow::Error::from(AppError::NotFoundEntry))?;
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.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, located by its UUID.
/// Returns a map field_name → decrypted Value.
pub async fn get_all_secrets_by_id(
pool: &PgPool,
entry_id: Uuid,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, Value>> {
// Validate entry exists (and that it belongs to the requesting user)
resolve_entry_by_id(pool, entry_id, user_id)
.await
.map_err(|_| anyhow::Error::from(AppError::NotFoundEntry))?;
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.name.clone(), decrypted);
}
Ok(map)
}

View File

@@ -1,64 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::service::search::resolve_entry;
#[derive(Debug, serde::Serialize)]
pub struct HistoryEntry {
pub version: i64,
pub action: String,
pub created_at: String,
}
/// Return version history for the entry identified by `name`.
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn run(
pool: &PgPool,
name: &str,
folder: Option<&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 entry = resolve_entry(pool, name, folder, user_id).await?;
let rows: Vec<Row> = sqlx::query_as(
"SELECT DISTINCT ON (version) version, action, created_at \
FROM entries_history \
WHERE entry_id = $1 \
ORDER BY version DESC, id DESC \
LIMIT $2",
)
.bind(entry.id)
.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,
name: &str,
folder: Option<&str>,
limit: u32,
user_id: Option<Uuid>,
) -> Result<Value> {
let entries = run(pool, name, folder, limit, user_id).await?;
Ok(serde_json::to_value(entries)?)
}

View File

@@ -1,180 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
use std::collections::HashMap;
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 folder = $1 AND name = $2 AND user_id IS NOT DISTINCT FROM $3)",
)
.bind(&entry.folder)
.bind(&entry.name)
.bind(params.user_id)
.fetch_one(pool)
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to check entry existence for '{}': {}",
entry.name,
e
)
})?;
if exists && !params.force {
return Err(anyhow::anyhow!(
"Import aborted: conflict on '{}'",
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);
let secret_types_map: HashMap<String, String> = entry
.secret_types
.as_ref()
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
match add_run(
pool,
AddParams {
name: &entry.name,
folder: &entry.folder,
entry_type: &entry.entry_type,
notes: &entry.notes,
tags: &entry.tags,
meta_entries: &meta_entries,
secret_entries: &secret_entries,
secret_types: &secret_types_map,
link_secret_names: &[],
user_id: params.user_id,
},
master_key,
)
.await
{
Ok(_) => {
inserted += 1;
}
Err(e) => {
tracing::error!(
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,
})
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap};
use crate::models::ExportEntry;
/// Mirrors the map built in `run` before `AddParams` (legacy files omit `secret_types`).
fn secret_types_for_add(entry: &ExportEntry) -> HashMap<String, String> {
entry
.secret_types
.as_ref()
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default()
}
#[test]
fn secret_types_three_kinds_round_trip_for_add_params() {
let mut types = BTreeMap::new();
types.insert("p".into(), "password".into());
types.insert("k".into(), "key".into());
types.insert("t".into(), "text".into());
let entry = ExportEntry {
name: "n".into(),
folder: "f".into(),
entry_type: "ty".into(),
notes: "".into(),
tags: vec![],
metadata: serde_json::json!({}),
secrets: Some(BTreeMap::new()),
secret_types: Some(types),
};
let m = secret_types_for_add(&entry);
assert_eq!(m.get("p").map(String::as_str), Some("password"));
assert_eq!(m.get("k").map(String::as_str), Some("key"));
assert_eq!(m.get("t").map(String::as_str), Some("text"));
}
#[test]
fn secret_types_absent_defaults_to_empty_map_like_legacy_export() {
let json =
r#"{"name":"a","folder":"","type":"","notes":"","tags":[],"metadata":{},"secrets":{}}"#;
let entry: ExportEntry = serde_json::from_str(json).unwrap();
assert!(entry.secret_types.is_none());
assert!(secret_types_for_add(&entry).is_empty());
}
}

View File

@@ -1,15 +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 relations;
pub mod rollback;
pub mod search;
pub mod update;
pub mod user;
pub mod util;

View File

@@ -1,324 +0,0 @@
use std::collections::{BTreeSet, HashMap};
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::AppError;
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct RelationEntrySummary {
pub id: Uuid,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct EntryRelations {
pub parents: Vec<RelationEntrySummary>,
pub children: Vec<RelationEntrySummary>,
}
pub async fn add_parent_relation(
pool: &PgPool,
parent_entry_id: Uuid,
child_entry_id: Uuid,
user_id: Option<Uuid>,
) -> Result<()> {
if parent_entry_id == child_entry_id {
return Err(AppError::Validation {
message: "entry cannot reference itself".to_string(),
}
.into());
}
let mut tx = pool.begin().await?;
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
let cycle_exists: bool = sqlx::query_scalar(
"WITH RECURSIVE descendants AS ( \
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
UNION \
SELECT er.child_entry_id \
FROM entry_relations er \
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
) \
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
)
.bind(child_entry_id)
.bind(parent_entry_id)
.fetch_one(&mut *tx)
.await?;
if cycle_exists {
tx.rollback().await?;
return Err(AppError::Validation {
message: "adding this relation would create a cycle".to_string(),
}
.into());
}
sqlx::query(
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) \
VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(parent_entry_id)
.bind(child_entry_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn remove_parent_relation(
pool: &PgPool,
parent_entry_id: Uuid,
child_entry_id: Uuid,
user_id: Option<Uuid>,
) -> Result<()> {
let mut tx = pool.begin().await?;
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
sqlx::query("DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2")
.bind(parent_entry_id)
.bind(child_entry_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn set_parent_relations(
pool: &PgPool,
child_entry_id: Uuid,
parent_entry_ids: &[Uuid],
user_id: Option<Uuid>,
) -> Result<()> {
let deduped: Vec<Uuid> = parent_entry_ids
.iter()
.copied()
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if deduped.contains(&child_entry_id) {
return Err(AppError::Validation {
message: "entry cannot reference itself".to_string(),
}
.into());
}
let mut tx = pool.begin().await?;
let mut validate_ids = Vec::with_capacity(deduped.len() + 1);
validate_ids.push(child_entry_id);
validate_ids.extend(deduped.iter().copied());
validate_live_entries(&mut tx, &validate_ids, user_id).await?;
let current_parent_ids: Vec<Uuid> =
sqlx::query_scalar("SELECT parent_entry_id FROM entry_relations WHERE child_entry_id = $1")
.bind(child_entry_id)
.fetch_all(&mut *tx)
.await?;
let current: BTreeSet<Uuid> = current_parent_ids.into_iter().collect();
let target: BTreeSet<Uuid> = deduped.iter().copied().collect();
for parent_id in current.difference(&target) {
sqlx::query(
"DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2",
)
.bind(*parent_id)
.bind(child_entry_id)
.execute(&mut *tx)
.await?;
}
for parent_id in target.difference(&current) {
let cycle_exists: bool = sqlx::query_scalar(
"WITH RECURSIVE descendants AS ( \
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
UNION \
SELECT er.child_entry_id \
FROM entry_relations er \
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
) \
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
)
.bind(child_entry_id)
.bind(*parent_id)
.fetch_one(&mut *tx)
.await?;
if cycle_exists {
tx.rollback().await?;
return Err(AppError::Validation {
message: "adding this relation would create a cycle".to_string(),
}
.into());
}
sqlx::query(
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) VALUES ($1, $2) \
ON CONFLICT DO NOTHING",
)
.bind(*parent_id)
.bind(child_entry_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn get_relations_for_entries(
pool: &PgPool,
entry_ids: &[Uuid],
user_id: Option<Uuid>,
) -> Result<HashMap<Uuid, EntryRelations>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
#[derive(sqlx::FromRow)]
struct ParentRow {
owner_entry_id: Uuid,
id: Uuid,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
}
#[derive(sqlx::FromRow)]
struct ChildRow {
owner_entry_id: Uuid,
id: Uuid,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
}
let (parents, children): (Vec<ParentRow>, Vec<ChildRow>) = if let Some(uid) = user_id {
let parents = sqlx::query_as(
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
FROM entry_relations er \
JOIN entries p ON p.id = er.parent_entry_id \
JOIN entries c ON c.id = er.child_entry_id \
WHERE er.child_entry_id = ANY($1) \
AND p.user_id = $2 AND c.user_id = $2 \
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
ORDER BY er.child_entry_id, p.name ASC",
)
.bind(entry_ids)
.bind(uid)
.fetch_all(pool);
let children = sqlx::query_as(
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
FROM entry_relations er \
JOIN entries c ON c.id = er.child_entry_id \
JOIN entries p ON p.id = er.parent_entry_id \
WHERE er.parent_entry_id = ANY($1) \
AND p.user_id = $2 AND c.user_id = $2 \
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
ORDER BY er.parent_entry_id, c.name ASC",
)
.bind(entry_ids)
.bind(uid)
.fetch_all(pool);
(parents.await?, children.await?)
} else {
let parents = sqlx::query_as(
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
FROM entry_relations er \
JOIN entries p ON p.id = er.parent_entry_id \
JOIN entries c ON c.id = er.child_entry_id \
WHERE er.child_entry_id = ANY($1) \
AND p.user_id IS NULL AND c.user_id IS NULL \
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
ORDER BY er.child_entry_id, p.name ASC",
)
.bind(entry_ids)
.fetch_all(pool);
let children = sqlx::query_as(
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
FROM entry_relations er \
JOIN entries c ON c.id = er.child_entry_id \
JOIN entries p ON p.id = er.parent_entry_id \
WHERE er.parent_entry_id = ANY($1) \
AND p.user_id IS NULL AND c.user_id IS NULL \
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
ORDER BY er.parent_entry_id, c.name ASC",
)
.bind(entry_ids)
.fetch_all(pool);
(parents.await?, children.await?)
};
let mut map: HashMap<Uuid, EntryRelations> = entry_ids
.iter()
.copied()
.map(|id| (id, EntryRelations::default()))
.collect();
for row in parents {
map.entry(row.owner_entry_id)
.or_default()
.parents
.push(RelationEntrySummary {
id: row.id,
folder: row.folder,
entry_type: row.entry_type,
name: row.name,
});
}
for row in children {
map.entry(row.owner_entry_id)
.or_default()
.children
.push(RelationEntrySummary {
id: row.id,
folder: row.folder,
entry_type: row.entry_type,
name: row.name,
});
}
Ok(map)
}
async fn validate_live_entries(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
entry_ids: &[Uuid],
user_id: Option<Uuid>,
) -> Result<()> {
let unique_ids: Vec<Uuid> = entry_ids
.iter()
.copied()
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let live_count: i64 = if let Some(uid) = user_id {
sqlx::query_scalar(
"SELECT COUNT(*)::bigint FROM entries \
WHERE id = ANY($1) AND user_id = $2 AND deleted_at IS NULL",
)
.bind(&unique_ids)
.bind(uid)
.fetch_one(&mut **tx)
.await?
} else {
sqlx::query_scalar(
"SELECT COUNT(*)::bigint FROM entries \
WHERE id = ANY($1) AND user_id IS NULL AND deleted_at IS NULL",
)
.bind(&unique_ids)
.fetch_one(&mut **tx)
.await?
};
if live_count != unique_ids.len() as i64 {
return Err(AppError::NotFoundEntry.into());
}
Ok(())
}

View File

@@ -1,343 +0,0 @@
use std::collections::HashSet;
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::error::AppError;
use crate::models::EntryWriteRow;
#[derive(Debug, serde::Serialize)]
pub struct RollbackResult {
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
pub restored_version: i64,
}
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
pub async fn run(
pool: &PgPool,
entry_id: Uuid,
to_version: Option<i64>,
user_id: Option<Uuid>,
) -> Result<RollbackResult> {
#[derive(sqlx::FromRow)]
struct EntryHistoryRow {
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
}
let mut tx = pool.begin().await?;
let live: Option<EntryWriteRow> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
)
.bind(entry_id)
.bind(uid)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL FOR UPDATE",
)
.bind(entry_id)
.fetch_optional(&mut *tx)
.await?
};
let lr = live.ok_or(AppError::NotFoundEntry)?;
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
sqlx::query_as(
"SELECT folder, type, name, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1",
)
.bind(entry_id)
.bind(ver)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT folder, type, name, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
)
.bind(entry_id)
.fetch_optional(&mut *tx)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for entry '{}'{}.",
lr.name,
to_version
.map(|v| format!(" at version {}", v))
.unwrap_or_default()
)
})?;
let snap_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata);
let snap_metadata = db::strip_secret_snapshot_from_metadata(&snap.metadata);
let live_entry_id = {
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
lr.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: lr.id,
user_id,
folder: &lr.folder,
entry_type: &lr.entry_type,
name: &lr.name,
version: lr.version,
action: "rollback",
tags: &lr.tags,
metadata: &history_metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry before rollback");
}
#[derive(sqlx::FromRow)]
struct LiveField {
id: Uuid,
name: String,
encrypted: Vec<u8>,
}
let live_fields: Vec<LiveField> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.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 {
secret_id: f.id,
name: &f.name,
encrypted: &f.encrypted,
action: "rollback",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field before rollback");
}
}
sqlx::query(
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
version = version + 1, updated_at = NOW() WHERE id = $7",
)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(&snap.name)
.bind(&lr.notes)
.bind(&snap.tags)
.bind(&snap_metadata)
.bind(lr.id)
.execute(&mut *tx)
.await?;
lr.id
};
if let Some(secret_snapshot) = snap_secret_snapshot {
restore_entry_secrets(&mut tx, live_entry_id, user_id, &secret_snapshot).await?;
}
crate::audit::log_tx(
&mut tx,
user_id,
"rollback",
&snap.folder,
&snap.entry_type,
&snap.name,
serde_json::json!({
"entry_id": entry_id,
"restored_version": snap.version,
"original_action": snap.action,
}),
)
.await;
tx.commit().await?;
Ok(RollbackResult {
name: snap.name,
folder: snap.folder,
entry_type: snap.entry_type,
restored_version: snap.version,
})
}
async fn restore_entry_secrets(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
entry_id: Uuid,
user_id: Option<Uuid>,
snapshot: &[db::EntrySecretSnapshot],
) -> Result<()> {
#[derive(sqlx::FromRow)]
struct LinkedSecret {
id: Uuid,
name: String,
encrypted: Vec<u8>,
}
let linked: Vec<LinkedSecret> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(entry_id)
.fetch_all(&mut **tx)
.await?;
let target_names: HashSet<&str> = snapshot.iter().map(|s| s.name.as_str()).collect();
for s in &linked {
if target_names.contains(s.name.as_str()) {
continue;
}
if let Err(e) = db::snapshot_secret_history(
tx,
db::SecretSnapshotParams {
secret_id: s.id,
name: &s.name,
encrypted: &s.encrypted,
action: "rollback",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret before rollback unlink");
}
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(entry_id)
.bind(s.id)
.execute(&mut **tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE s.id = $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(s.id)
.execute(&mut **tx)
.await?;
}
for snap in snapshot {
let encrypted = ::hex::decode(&snap.encrypted_hex).map_err(|e| {
anyhow::anyhow!("invalid secret snapshot data for '{}': {}", snap.name, e)
})?;
#[derive(sqlx::FromRow)]
struct ExistingSecret {
id: Uuid,
encrypted: Vec<u8>,
}
let existing: Option<ExistingSecret> = if let Some(uid) = user_id {
sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 AND name = $2")
.bind(uid)
.bind(&snap.name)
.fetch_optional(&mut **tx)
.await?
} else {
sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id IS NULL AND name = $1")
.bind(&snap.name)
.fetch_optional(&mut **tx)
.await?
};
let secret_id = if let Some(ex) = existing {
if ex.encrypted != encrypted
&& let Err(e) = db::snapshot_secret_history(
tx,
db::SecretSnapshotParams {
secret_id: ex.id,
name: &snap.name,
encrypted: &ex.encrypted,
action: "rollback",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret before rollback restore");
}
sqlx::query(
"UPDATE secrets SET type = $1, encrypted = $2, version = version + 1, updated_at = NOW() \
WHERE id = $3",
)
.bind(&snap.secret_type)
.bind(&encrypted)
.bind(ex.id)
.execute(&mut **tx)
.await?;
ex.id
} else if let Some(uid) = user_id {
sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(uid)
.bind(&snap.name)
.bind(&snap.secret_type)
.bind(&encrypted)
.fetch_one(&mut **tx)
.await?
} else {
sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, $2, $3) RETURNING id",
)
.bind(&snap.name)
.bind(&snap.secret_type)
.bind(&encrypted)
.fetch_one(&mut **tx)
.await?
};
sqlx::query(
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(entry_id)
.bind(secret_id)
.execute(&mut **tx)
.await?;
}
Ok(())
}

View File

@@ -1,421 +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 = 10_000;
/// Build an ILIKE pattern for fuzzy matching, escaping `%` and `_` literals.
pub fn ilike_pattern(value: &str) -> String {
format!(
"%{}%",
value
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
)
}
pub struct SearchParams<'a> {
pub folder: Option<&'a str>,
pub entry_type: Option<&'a str>,
pub name: Option<&'a str>,
/// Fuzzy match on `entries.name` only (ILIKE with escaped `%`/`_`).
pub name_query: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
pub metadata_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>>,
}
/// List `entries` rows matching params (paged, ordered per `params.sort`).
/// Does not read the `secrets` table.
pub async fn list_entries(pool: &PgPool, params: SearchParams<'_>) -> Result<Vec<Entry>> {
fetch_entries_paged(pool, &params).await
}
/// Count `entries` rows matching the same filters as [`list_entries`] (ignores `sort` / `limit` / `offset`).
/// Does not read the `secrets` table.
pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result<i64> {
let (where_clause, _) = entry_where_clause_and_next_idx(a);
let sql = format!("SELECT COUNT(*)::bigint FROM entries {where_clause}");
let mut q = sqlx::query_scalar::<_, i64>(&sql);
if let Some(uid) = a.user_id {
q = q.bind(uid);
}
if let Some(v) = a.folder {
q = q.bind(v);
}
if let Some(v) = a.entry_type {
q = q.bind(v);
}
if let Some(v) = a.name {
q = q.bind(v);
}
if let Some(v) = a.name_query {
let pattern = ilike_pattern(v);
q = q.bind(pattern);
}
for tag in a.tags {
q = q.bind(tag);
}
if let Some(v) = a.query {
let pattern = ilike_pattern(v);
q = q.bind(pattern);
}
if let Some(v) = a.metadata_query {
let pattern = ilike_pattern(v);
q = q.bind(pattern);
}
let n = q.fetch_one(pool).await?;
Ok(n)
}
/// Shared WHERE clause and the next `$n` index (for LIMIT/OFFSET in paged queries).
fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if a.user_id.is_some() {
conditions.push(format!("user_id = ${}", idx));
idx += 1;
} else {
conditions.push("user_id IS NULL".to_string());
}
conditions.push("deleted_at IS NULL".to_string());
if a.folder.is_some() {
conditions.push(format!("folder = ${}", idx));
idx += 1;
}
if a.entry_type.is_some() {
conditions.push(format!("type = ${}", idx));
idx += 1;
}
if a.name.is_some() {
conditions.push(format!("name = ${}", idx));
idx += 1;
}
if a.name_query.is_some() {
conditions.push(format!("name ILIKE ${} ESCAPE '\\'", 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 folder ILIKE ${i} ESCAPE '\\' \
OR type ILIKE ${i} ESCAPE '\\' OR notes 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;
}
if a.metadata_query.is_some() {
conditions.push(format!(
"EXISTS (SELECT 1 FROM jsonb_path_query(metadata, 'strict $.** ? (@.type() != \"object\" && @.type() != \"array\")') AS val \
WHERE (val #>> '{{}}') ILIKE ${} ESCAPE '\\')",
idx
));
idx += 1;
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(where_clause, idx)
}
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_secrets_for_entries(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.
#[allow(clippy::too_many_arguments)]
pub async fn fetch_entries(
pool: &PgPool,
folder: Option<&str>,
entry_type: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
metadata_query: Option<&str>,
user_id: Option<Uuid>,
) -> Result<Vec<Entry>> {
let params = SearchParams {
folder,
entry_type,
name,
name_query: None,
tags,
query,
metadata_query,
sort: "name",
limit: FETCH_ALL_LIMIT,
offset: 0,
user_id,
};
list_entries(pool, params).await
}
async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<Entry>> {
let (where_clause, idx) = entry_where_clause_and_next_idx(a);
let order = match a.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
_ => "name ASC",
};
let limit_idx = idx;
let offset_idx = idx + 1;
let sql = format!(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at, deleted_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.folder {
q = q.bind(v);
}
if let Some(v) = a.entry_type {
q = q.bind(v);
}
if let Some(v) = a.name {
q = q.bind(v);
}
if let Some(v) = a.name_query {
let pattern = ilike_pattern(v);
q = q.bind(pattern);
}
for tag in a.tags {
q = q.bind(tag);
}
if let Some(v) = a.query {
let pattern = ilike_pattern(v);
q = q.bind(pattern);
}
if let Some(v) = a.metadata_query {
let pattern = ilike_pattern(v);
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 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<EntrySecretRow> = sqlx::query_as(
"SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = ANY($1) \
ORDER BY es.entry_id, es.sort_order, s.name",
)
.bind(entry_ids)
.fetch_all(pool)
.await?;
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
let entry_id = f.entry_id;
map.entry(entry_id).or_default().push(f.secret());
}
Ok(map)
}
/// Resolve exactly one entry by its UUID primary key.
///
/// Returns an error if the entry does not exist or does not belong to the given user.
pub async fn resolve_entry_by_id(
pool: &PgPool,
id: Uuid,
user_id: Option<Uuid>,
) -> Result<crate::models::Entry> {
let row: Option<EntryRaw> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
)
.bind(id)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL",
)
.bind(id)
.fetch_optional(pool)
.await?
};
row.map(Entry::from)
.ok_or_else(|| anyhow::anyhow!("Entry with id '{}' not found", id))
}
/// Resolve exactly one entry by name, with optional folder for disambiguation.
///
/// - If `folder` is provided: exact `(folder, name)` match.
/// - If `folder` is None and exactly one entry matches: returns it.
/// - If `folder` is None and multiple entries match: returns an error listing
/// the folders and asking the caller to specify one.
pub async fn resolve_entry(
pool: &PgPool,
name: &str,
folder: Option<&str>,
user_id: Option<Uuid>,
) -> Result<crate::models::Entry> {
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, None, user_id).await?;
match entries.len() {
0 => {
if let Some(f) = folder {
anyhow::bail!("Not found: '{}' in folder '{}'", name, f)
} else {
anyhow::bail!("Not found: '{}'", name)
}
}
1 => entries
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("internal: resolve_entry result vanished")),
_ => {
let folders: Vec<&str> = entries.iter().map(|e| e.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
entries.len(),
name,
folders.join(", ")
)
}
}
}
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
#[derive(sqlx::FromRow)]
struct EntryRaw {
id: Uuid,
user_id: Option<Uuid>,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
notes: String,
tags: Vec<String>,
metadata: Value,
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl From<EntryRaw> for Entry {
fn from(r: EntryRaw) -> Self {
Entry {
id: r.id,
user_id: r.user_id,
folder: r.folder,
entry_type: r.entry_type,
name: r.name,
notes: r.notes,
tags: r.tags,
metadata: r.metadata,
version: r.version,
created_at: r.created_at,
updated_at: r.updated_at,
deleted_at: r.deleted_at,
}
}
}
#[derive(sqlx::FromRow)]
struct EntrySecretRow {
entry_id: Uuid,
id: Uuid,
user_id: Option<Uuid>,
name: String,
#[sqlx(rename = "type")]
secret_type: String,
encrypted: Vec<u8>,
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl EntrySecretRow {
fn secret(self) -> SecretField {
SecretField {
id: self.id,
user_id: self.user_id,
name: self.name,
secret_type: self.secret_type,
encrypted: self.encrypted,
version: self.version,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ilike_pattern_escapes_backslash_percent_and_underscore() {
assert_eq!(ilike_pattern(r"hello\_100%"), r"%hello\\\_100\%%");
}
}

View File

@@ -1,562 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::error::{AppError, DbErrorContext};
use crate::models::{EntryRow, EntryWriteRow};
use crate::service::add::{
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
parse_kv, remove_path,
};
use crate::service::util::user_scope_condition;
#[derive(Debug, serde::Serialize)]
pub struct UpdateResult {
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: 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 linked_secrets: Vec<String>,
pub unlinked_secrets: Vec<String>,
}
pub struct UpdateParams<'a> {
pub name: &'a str,
/// Optional folder for disambiguation when multiple entries share the same name.
pub folder: Option<&'a str>,
pub notes: Option<&'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 secret_types: &'a std::collections::HashMap<String, String>,
pub remove_secrets: &'a [String],
pub link_secret_names: &'a [String],
pub unlink_secret_names: &'a [String],
pub user_id: Option<Uuid>,
}
pub async fn run(
pool: &PgPool,
params: UpdateParams<'_>,
master_key: &[u8; 32],
) -> Result<UpdateResult> {
if params.name.chars().count() > 256 {
anyhow::bail!("name must be at most 256 characters");
}
let mut tx = pool.begin().await?;
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
let mut idx = 1i32;
let user_cond = user_scope_condition(params.user_id, &mut idx);
let mut conditions = vec![user_cond];
if params.folder.is_some() {
conditions.push(format!("folder = ${}", idx));
idx += 1;
}
conditions.push(format!("name = ${}", idx));
let sql = format!(
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
WHERE {} AND deleted_at IS NULL FOR UPDATE",
conditions.join(" AND ")
);
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
if let Some(uid) = params.user_id {
q = q.bind(uid);
}
if let Some(folder) = params.folder {
q = q.bind(folder);
}
q = q.bind(params.name);
let rows = q.fetch_all(&mut *tx).await?;
let row = match rows.len() {
0 => {
tx.rollback().await?;
return Err(AppError::NotFoundEntry.into());
}
1 => rows
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?,
_ => {
tx.rollback().await?;
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
params.name,
folders.join(", ")
)
}
};
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
row.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: params.user_id,
folder: &row.folder,
entry_type: &row.entry_type,
name: params.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &history_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 new_notes = params.notes.unwrap_or(&row.notes);
let result = sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, notes = $3, \
version = version + 1, updated_at = NOW() \
WHERE id = $4 AND version = $5",
)
.bind(&tags)
.bind(&metadata)
.bind(new_notes)
.bind(row.id)
.bind(row.version)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
tx.rollback().await?;
return Err(AppError::ConcurrentModification.into());
}
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 s.id, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.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 {
secret_id: ef.id,
name: field_name,
encrypted: &ef.encrypted,
action: "update",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
if let Some(ef) = ef {
sqlx::query(
"UPDATE secrets SET encrypted = $1, version = version + 1, updated_at = NOW() WHERE id = $2",
)
.bind(&encrypted)
.bind(ef.id)
.execute(&mut *tx)
.await?;
} else {
let secret_type = params
.secret_types
.get(field_name)
.map(|s| s.as_str())
.unwrap_or("text");
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(params.user_id)
.bind(field_name.to_string())
.bind(secret_type)
.bind(&encrypted)
.fetch_one(&mut *tx)
.await
.map_err(|e| AppError::from_db_error(e, DbErrorContext::secret_name(field_name)))?;
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(row.id)
.bind(secret_id)
.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 s.id, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.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 {
secret_id: f.id,
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 entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(row.id)
.bind(f.id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE s.id = $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(f.id)
.execute(&mut *tx)
.await?;
}
}
// Link existing secrets by name
let mut linked_secrets = Vec::new();
for link_name in params.link_secret_names {
let link_name = link_name.trim();
if link_name.is_empty() {
anyhow::bail!("link_secret_names contains an empty name");
}
let secret_ids: Vec<Uuid> = if let Some(uid) = params.user_id {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2")
.bind(uid)
.bind(link_name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1")
.bind(link_name)
.fetch_all(&mut *tx)
.await?
};
match secret_ids.len() {
0 => anyhow::bail!("Not found: secret named '{}'", link_name),
1 => {
sqlx::query(
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(row.id)
.bind(secret_ids[0])
.execute(&mut *tx)
.await?;
linked_secrets.push(link_name.to_string());
}
n => anyhow::bail!(
"Ambiguous: {} secrets named '{}' found. Please deduplicate names first.",
n,
link_name
),
}
}
// Unlink secrets by name
let mut unlinked_secrets = Vec::new();
for unlink_name in params.unlink_secret_names {
let unlink_name = unlink_name.trim();
if unlink_name.is_empty() {
continue;
}
#[derive(sqlx::FromRow)]
struct SecretToUnlink {
id: Uuid,
encrypted: Vec<u8>,
}
let secret: Option<SecretToUnlink> = sqlx::query_as(
"SELECT s.id, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.name = $2",
)
.bind(row.id)
.bind(unlink_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(s) = secret {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
secret_id: s.id,
name: unlink_name,
encrypted: &s.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history before unlink");
}
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(row.id)
.bind(s.id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE s.id = $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(s.id)
.execute(&mut *tx)
.await?;
unlinked_secrets.push(unlink_name.to_string());
}
}
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",
&row.folder,
&row.entry_type,
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,
"linked_secrets": linked_secrets,
"unlinked_secrets": unlinked_secrets,
}),
)
.await;
tx.commit().await?;
Ok(UpdateResult {
name: params.name.to_string(),
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
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,
linked_secrets,
unlinked_secrets,
})
}
/// Update non-sensitive entry columns by primary key (multi-tenant: `user_id` must match).
/// Does not read or modify `secrets` rows.
pub struct UpdateEntryFieldsByIdParams<'a> {
pub folder: &'a str,
pub entry_type: &'a str,
pub name: &'a str,
pub notes: &'a str,
pub tags: &'a [String],
pub metadata: &'a serde_json::Value,
}
pub async fn update_fields_by_id(
pool: &PgPool,
entry_id: Uuid,
user_id: Uuid,
params: UpdateEntryFieldsByIdParams<'_>,
) -> Result<()> {
if params.folder.chars().count() > 128 {
anyhow::bail!("folder must be at most 128 characters");
}
if params.entry_type.chars().count() > 64 {
anyhow::bail!("type must be at most 64 characters");
}
if params.name.chars().count() > 256 {
anyhow::bail!("name must be at most 256 characters");
}
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
return Err(AppError::NotFoundEntry.into());
}
};
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
row.metadata.clone()
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: Some(user_id),
folder: &row.folder,
entry_type: &row.entry_type,
name: &row.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &history_metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before web update");
}
let entry_type = params.entry_type.trim();
let res = sqlx::query(
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
version = version + 1, updated_at = NOW() \
WHERE id = $7 AND version = $8",
)
.bind(params.folder)
.bind(entry_type)
.bind(params.name)
.bind(params.notes)
.bind(params.tags)
.bind(params.metadata)
.bind(row.id)
.bind(row.version)
.execute(&mut *tx)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref d) = e
&& d.code().as_deref() == Some("23505")
{
return AppError::ConflictEntryName {
folder: params.folder.to_string(),
name: params.name.to_string(),
};
}
AppError::Internal(e.into())
})?;
if res.rows_affected() == 0 {
tx.rollback().await?;
return Err(AppError::ConcurrentModification.into());
}
crate::audit::log_tx(
&mut tx,
Some(user_id),
"update",
params.folder,
entry_type,
params.name,
serde_json::json!({
"source": "web",
"entry_id": entry_id,
"fields": ["folder", "type", "name", "notes", "tags", "metadata"],
}),
)
.await;
tx.commit().await?;
Ok(())
}

View File

@@ -1,349 +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)> {
// Use a transaction with FOR UPDATE to prevent TOCTOU race conditions
let mut tx = pool.begin().await?;
// Check if this OAuth account already exists (with row lock)
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 FOR UPDATE",
)
.bind(&profile.provider)
.bind(&profile.provider_id)
.fetch_optional(&mut *tx)
.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, key_version, created_at, updated_at \
FROM users WHERE id = $1",
)
.bind(oa.user_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().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 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, key_version, 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))
}
/// Re-encrypt all of a user's secrets from `old_key` to `new_key` and update the key metadata.
///
/// Runs entirely inside a single database transaction: if any secret fails to re-encrypt
/// the whole operation is rolled back, leaving the database unchanged.
pub async fn change_user_key(
pool: &PgPool,
user_id: Uuid,
old_key: &[u8; 32],
new_key: &[u8; 32],
new_salt: &[u8],
new_key_check: &[u8],
new_key_params: &Value,
) -> Result<()> {
let mut tx = pool.begin().await?;
let secrets: Vec<(uuid::Uuid, Vec<u8>)> =
sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 FOR UPDATE")
.bind(user_id)
.fetch_all(&mut *tx)
.await?;
for (id, encrypted) in &secrets {
let plaintext = crate::crypto::decrypt(old_key, encrypted)?;
let new_encrypted = crate::crypto::encrypt(new_key, &plaintext)?;
sqlx::query("UPDATE secrets SET encrypted = $1, updated_at = NOW() WHERE id = $2")
.bind(&new_encrypted)
.bind(id)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, \
key_version = key_version + 1, updated_at = NOW() \
WHERE id = $4",
)
.bind(new_salt)
.bind(new_key_check)
.bind(new_key_params)
.bind(user_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// 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, key_version, 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> {
// Use a transaction with FOR UPDATE to prevent TOCTOU race conditions
let mut tx = pool.begin().await?;
// Check if this provider_id is already linked to someone else (with row lock)
let conflict: Option<(Uuid,)> = sqlx::query_as(
"SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2 FOR UPDATE",
)
.bind(&profile.provider)
.bind(&profile.provider_id)
.fetch_optional(&mut *tx)
.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 FOR UPDATE",
)
.bind(user_id)
.bind(&profile.provider)
.fetch_optional(&mut *tx)
.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(&mut *tx)
.await?;
tx.commit().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 mut tx = pool.begin().await?;
let locked_accounts: Vec<(String,)> =
sqlx::query_as("SELECT provider FROM oauth_accounts WHERE user_id = $1 FOR UPDATE")
.bind(user_id)
.fetch_all(&mut *tx)
.await?;
let count = locked_accounts.len();
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(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
async fn maybe_test_pool() -> Option<PgPool> {
let database_url = match std::env::var("SECRETS_DATABASE_URL") {
Ok(v) => v,
Err(_) => {
eprintln!("skip user service tests: SECRETS_DATABASE_URL not set");
return None;
}
};
let pool = match sqlx::PgPool::connect(&database_url).await {
Ok(pool) => pool,
Err(e) => {
eprintln!("skip user service tests: cannot connect to database: {e}");
return None;
}
};
if let Err(e) = crate::db::migrate(&pool).await {
eprintln!("skip user service tests: migrate failed: {e}");
return None;
}
Some(pool)
}
async fn cleanup_user_rows(pool: &PgPool, user_id: Uuid) -> Result<()> {
sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1")
.bind(user_id)
.execute(pool)
.await?;
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
#[tokio::test]
async fn unbind_oauth_account_removes_only_requested_provider() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let user_id = Uuid::from_u128(rand::random());
cleanup_user_rows(&pool, user_id).await?;
sqlx::query("INSERT INTO users (id, name) VALUES ($1, '')")
.bind(user_id)
.execute(&pool)
.await?;
sqlx::query(
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
VALUES ($1, 'google', $2, NULL, NULL, NULL), \
($1, 'github', $3, NULL, NULL, NULL)",
)
.bind(user_id)
.bind(format!("google-{user_id}"))
.bind(format!("github-{user_id}"))
.execute(&pool)
.await?;
unbind_oauth_account(&pool, user_id, "github", Some("google")).await?;
let remaining: Vec<(String,)> = sqlx::query_as(
"SELECT provider FROM oauth_accounts WHERE user_id = $1 ORDER BY provider",
)
.bind(user_id)
.fetch_all(&pool)
.await?;
assert_eq!(remaining, vec![("google".to_string(),)]);
cleanup_user_rows(&pool, user_id).await?;
Ok(())
}
}

View File

@@ -1,27 +0,0 @@
use uuid::Uuid;
/// Returns a WHERE condition fragment for user scope and advances `idx` if `user_id` is Some.
///
/// - `Some(uid)` → `"user_id = $N"` with idx incremented.
/// - `None` → `"user_id IS NULL"` with idx unchanged.
///
/// # Usage
///
/// ```rust,ignore
/// let mut idx = 1i32;
/// let user_cond = user_scope_condition(user_id, &mut idx);
/// // idx is now 2 if user_id is Some, still 1 if None
/// let sql = format!("SELECT ... FROM entries WHERE {user_cond} AND name = ${idx}");
/// let mut q = sqlx::query_as::<_, Row>(&sql);
/// if let Some(uid) = user_id { q = q.bind(uid); }
/// q = q.bind(name);
/// ```
pub fn user_scope_condition(user_id: Option<Uuid>, idx: &mut i32) -> String {
if user_id.is_some() {
let s = format!("user_id = ${}", *idx);
*idx += 1;
s
} else {
"user_id IS NULL".to_string()
}
}

View File

@@ -1,4 +0,0 @@
/// Canonical secret type options for UI dropdowns.
pub const SECRET_TYPE_OPTIONS: &[&str] = &[
"text", "password", "token", "api-key", "ssh-key", "url", "phone", "id-card",
];

View File

@@ -1,24 +0,0 @@
[package]
name = "secrets-mcp-local"
version = "0.1.0"
edition.workspace = true
description = "Local MCP gateway for onboarding, unlock caching, and delegated target execution"
license = "MIT OR Apache-2.0"
[[bin]]
name = "secrets-mcp-local"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
axum = "0.8"
dotenvy.workspace = true
reqwest = { workspace = true, features = ["stream"] }
secrets-core = { path = "../secrets-core" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber = { workspace = true, features = ["env-filter"] }
url = "2"
uuid.workspace = true

View File

@@ -1,212 +0,0 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::cache::{BoundState, PendingBindState};
use crate::server::AppState;
#[derive(Deserialize)]
pub struct BindExchangeBody {
bind_id: Option<String>,
device_code: Option<String>,
}
fn bind_exchange_error_message(value: &Value) -> String {
value
.get("error")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
.or_else(|| {
value
.get("message")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| value.to_string())
}
pub async fn refresh_bound_state(state: &AppState) {
let api_key = {
let guard = state.cache.read().await;
guard.bound.as_ref().map(|bound| bound.api_key.clone())
};
let Some(api_key) = api_key else {
return;
};
if let Ok(refreshed) = state.remote.bind_refresh(&api_key).await {
let mut guard = state.cache.write().await;
if matches!(refreshed.status, 401 | 404) {
guard.clear_bound_and_unlock();
return;
}
if let Some(refreshed) = refreshed.body {
let clear_unlock = if let Some(bound) = guard.bound.as_mut() {
let changed = bound.key_version != refreshed.key_version;
bound.key_version = refreshed.key_version;
bound.key_salt_hex = refreshed.key_salt_hex.clone();
bound.key_check_hex = refreshed.key_check_hex.clone();
bound.key_params = refreshed.key_params.clone();
changed
} else {
false
};
if clear_unlock {
guard.clear_unlock_and_exec();
}
}
}
}
pub async fn start_bind(state: &AppState) -> Result<serde_json::Value, (StatusCode, String)> {
let res = state
.remote
.bind_start()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("bind/start failed: {e}")))?;
let started_at = std::time::Instant::now();
let expires_at = started_at + std::time::Duration::from_secs(res.expires_in_secs);
let mut guard = state.cache.write().await;
guard.clear_bound_and_unlock();
guard.pending_bind = Some(PendingBindState {
bind_id: res.bind_id.clone(),
device_code: res.device_code.clone(),
approve_url: res.approve_url.clone(),
expires_at,
started_at,
});
Ok(json!({
"ok": true,
"bind_id": res.bind_id,
"device_code": res.device_code,
"approve_url": res.approve_url,
"expires_in_secs": res.expires_in_secs,
"onboarding_url": format!("http://{}/", state.config.bind),
"next_action": "在浏览器打开 approve_url 完成授权,然后继续轮询 local_bind_exchange",
}))
}
pub async fn exchange_bind(
state: &AppState,
bind_id: Option<String>,
device_code: Option<String>,
) -> Result<(StatusCode, serde_json::Value), (StatusCode, String)> {
let (bind_id, device_code) = if let (Some(bind_id), Some(device_code)) = (bind_id, device_code)
{
(bind_id, device_code)
} else {
let guard = state.cache.read().await;
let pending = guard.pending_bind.as_ref().ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
"missing bind session; call /local/bind/start first".to_string(),
)
})?;
(pending.bind_id.clone(), pending.device_code.clone())
};
let result = state
.remote
.bind_exchange(&bind_id, &device_code)
.await
.map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("bind/exchange failed: {e}"),
)
})?;
let status = result.status;
let payload = result.body;
if status == 202 || payload.get("status").and_then(|v| v.as_str()) == Some("pending") {
let approve_url = {
let guard = state.cache.read().await;
guard
.pending_bind
.as_ref()
.filter(|pending| pending.bind_id == bind_id && pending.device_code == device_code)
.map(|pending| pending.approve_url.clone())
};
return Ok((
StatusCode::ACCEPTED,
json!({
"ok": false,
"status": "pending",
"bind_id": bind_id,
"device_code": device_code,
"approve_url": approve_url,
"next_action": "继续等待远端授权完成,或重新打开 approve_url",
}),
));
}
if !(200..300).contains(&status) {
return Err((
StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY),
bind_exchange_error_message(&payload),
));
}
let payload: crate::remote::BindExchangeResponse =
serde_json::from_value(payload).map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("invalid bind/exchange response: {e}"),
)
})?;
let api_key = payload.api_key.ok_or_else(|| {
(
StatusCode::BAD_GATEWAY,
"bind/exchange missing api_key".to_string(),
)
})?;
let user_id = payload.user_id.ok_or_else(|| {
(
StatusCode::BAD_GATEWAY,
"bind/exchange missing user_id".to_string(),
)
})?;
let mut guard = state.cache.write().await;
guard.clear_pending_bind();
guard.bound = Some(BoundState {
user_id,
api_key,
key_salt_hex: payload.key_salt_hex,
key_check_hex: payload.key_check_hex,
key_params: payload.key_params,
key_version: payload.key_version.unwrap_or(0),
bound_at: std::time::Instant::now(),
});
guard.clear_unlock_and_exec();
Ok((
StatusCode::OK,
json!({
"ok": true,
"status": "bound",
"unlock_url": format!("http://{}/unlock", state.config.bind),
"onboarding_url": format!("http://{}/", state.config.bind),
"next_action": "打开本地 unlock 页面完成 passphrase 解锁",
}),
))
}
pub async fn bind_start(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let payload = start_bind(&state).await?;
Ok((StatusCode::OK, axum::Json(payload)))
}
pub async fn bind_exchange(
State(state): State<AppState>,
axum::Json(input): axum::Json<BindExchangeBody>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let (status, payload) = exchange_bind(&state, input.bind_id, input.device_code).await?;
Ok((status, axum::Json(payload)))
}
pub async fn unbind(State(state): State<AppState>) -> impl IntoResponse {
let mut guard = state.cache.write().await;
guard.clear_bound_and_unlock();
(StatusCode::OK, axum::Json(json!({ "ok": true })))
}

View File

@@ -1,234 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::target::ExecutionTarget;
#[derive(Clone)]
pub struct BoundState {
pub user_id: Uuid,
pub api_key: String,
pub key_salt_hex: Option<String>,
pub key_check_hex: Option<String>,
pub key_params: Option<Value>,
pub key_version: i64,
pub bound_at: Instant,
}
#[derive(Clone)]
pub struct UnlockState {
pub encryption_key_hex: String,
pub expires_at: Instant,
pub last_used_at: Instant,
}
#[derive(Clone)]
pub struct ExecContext {
pub target: ExecutionTarget,
pub expires_at: Instant,
pub last_used_at: Instant,
}
#[derive(Clone)]
pub struct PendingBindState {
pub bind_id: String,
pub device_code: String,
pub approve_url: String,
pub expires_at: Instant,
pub started_at: Instant,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GatewayPhase {
Bootstrap,
PendingUnlock,
Ready,
}
#[derive(Default)]
pub struct GatewayCache {
pub pending_bind: Option<PendingBindState>,
pub bound: Option<BoundState>,
pub unlock: Option<UnlockState>,
pub exec_contexts: HashMap<String, ExecContext>,
}
impl GatewayCache {
pub fn clear_bound_and_unlock(&mut self) {
self.pending_bind = None;
self.bound = None;
self.unlock = None;
self.exec_contexts.clear();
}
pub fn clear_pending_bind(&mut self) {
self.pending_bind = None;
}
pub fn clear_unlock_and_exec(&mut self) {
self.unlock = None;
self.exec_contexts.clear();
}
pub fn phase(&self, now: Instant) -> GatewayPhase {
if self.bound.is_none() {
return GatewayPhase::Bootstrap;
}
if self
.unlock
.as_ref()
.is_some_and(|unlock| unlock.expires_at > now && !unlock.encryption_key_hex.is_empty())
{
GatewayPhase::Ready
} else {
GatewayPhase::PendingUnlock
}
}
}
pub type SharedCache = Arc<RwLock<GatewayCache>>;
pub fn new_cache() -> SharedCache {
Arc::new(RwLock::new(GatewayCache::default()))
}
fn cleanup_expired(cache: &mut GatewayCache, now: Instant) {
if cache
.pending_bind
.as_ref()
.is_some_and(|bind| bind.expires_at <= now)
{
cache.pending_bind = None;
}
if let Some(unlock) = cache.unlock.as_ref()
&& unlock.expires_at <= now
{
cache.clear_unlock_and_exec();
}
cache.exec_contexts.retain(|_, ctx| ctx.expires_at > now);
if cache.unlock.is_none() {
cache.exec_contexts.clear();
}
}
pub fn spawn_cleanup_task(cache: SharedCache) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
let now = Instant::now();
let mut guard = cache.write().await;
cleanup_expired(&mut guard, now);
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use crate::target::ResolvedTarget;
#[tokio::test]
async fn cleanup_task_clears_expired_unlock() {
let mut cache = GatewayCache {
pending_bind: None,
bound: None,
unlock: Some(UnlockState {
encryption_key_hex: "11".repeat(32),
expires_at: Instant::now() - Duration::from_secs(1),
last_used_at: Instant::now(),
}),
exec_contexts: HashMap::new(),
};
cleanup_expired(&mut cache, Instant::now());
assert!(cache.unlock.is_none());
assert!(cache.exec_contexts.is_empty());
}
#[test]
fn clear_unlock_and_exec_drops_entry_contexts() {
let mut cache = GatewayCache {
pending_bind: None,
bound: None,
unlock: Some(UnlockState {
encryption_key_hex: "11".repeat(32),
expires_at: Instant::now() + Duration::from_secs(30),
last_used_at: Instant::now(),
}),
exec_contexts: HashMap::from([(
"entry-1".to_string(),
ExecContext {
target: ExecutionTarget {
resolved: ResolvedTarget {
id: "entry-1".to_string(),
folder: "refining".to_string(),
name: "api".to_string(),
entry_type: Some("service".to_string()),
},
env: BTreeMap::from([(
"TARGET_API_KEY".to_string(),
"sk_test".to_string(),
)]),
},
expires_at: Instant::now() + Duration::from_secs(30),
last_used_at: Instant::now(),
},
)]),
};
cache.clear_unlock_and_exec();
assert!(cache.unlock.is_none());
assert!(cache.exec_contexts.is_empty());
}
#[test]
fn cleanup_drops_expired_pending_bind() {
let mut cache = GatewayCache {
pending_bind: Some(PendingBindState {
bind_id: "bind-1".to_string(),
device_code: "device-1".to_string(),
approve_url: "http://example.com/approve".to_string(),
expires_at: Instant::now() - Duration::from_secs(1),
started_at: Instant::now() - Duration::from_secs(30),
}),
bound: None,
unlock: None,
exec_contexts: HashMap::new(),
};
cleanup_expired(&mut cache, Instant::now());
assert!(cache.pending_bind.is_none());
}
#[test]
fn phase_transitions_match_bound_and_unlock() {
let now = Instant::now();
let mut cache = GatewayCache::default();
assert_eq!(cache.phase(now), GatewayPhase::Bootstrap);
cache.bound = Some(BoundState {
user_id: Uuid::nil(),
api_key: "api-key".to_string(),
key_salt_hex: None,
key_check_hex: None,
key_params: None,
key_version: 0,
bound_at: now,
});
assert_eq!(cache.phase(now), GatewayPhase::PendingUnlock);
cache.unlock = Some(UnlockState {
encryption_key_hex: "11".repeat(32),
expires_at: now + Duration::from_secs(60),
last_used_at: now,
});
assert_eq!(cache.phase(now), GatewayPhase::Ready);
}
}

View File

@@ -1,46 +0,0 @@
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::time::Duration;
use url::Url;
const DEFAULT_BIND: &str = "127.0.0.1:9316";
const DEFAULT_UNLOCK_TTL_SECS: u64 = 3600;
const DEFAULT_EXEC_CONTEXT_TTL_SECS: u64 = 3600;
#[derive(Clone)]
pub struct LocalConfig {
pub bind: SocketAddr,
pub remote_base_url: Url,
pub default_unlock_ttl: Duration,
pub default_exec_context_ttl: Duration,
}
fn load_env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.is_empty())
}
pub fn load_config() -> Result<LocalConfig> {
let bind = load_env("SECRETS_MCP_LOCAL_BIND").unwrap_or_else(|| DEFAULT_BIND.to_string());
let bind: SocketAddr = bind
.parse()
.with_context(|| format!("invalid SECRETS_MCP_LOCAL_BIND: {bind}"))?;
let remote_base_url: Url = load_env("SECRETS_REMOTE_BASE_URL")
.context("SECRETS_REMOTE_BASE_URL is required")?
.parse()
.context("invalid SECRETS_REMOTE_BASE_URL")?;
let unlock_ttl_secs: u64 = load_env("SECRETS_LOCAL_UNLOCK_TTL_SECS")
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_UNLOCK_TTL_SECS);
let exec_context_ttl_secs: u64 = load_env("SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS")
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_EXEC_CONTEXT_TTL_SECS);
Ok(LocalConfig {
bind,
remote_base_url,
default_unlock_ttl: Duration::from_secs(unlock_ttl_secs.clamp(60, 86400 * 7)),
default_exec_context_ttl: Duration::from_secs(exec_context_ttl_secs.clamp(60, 86400 * 7)),
})
}

View File

@@ -1,55 +0,0 @@
mod bind;
mod cache;
mod config;
mod exec;
mod mcp;
mod remote;
mod server;
mod target;
mod unlock;
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_mcp_local=info,tower_http=info".into()),
)
.init();
let config = config::load_config()?;
let remote = std::sync::Arc::new(remote::RemoteClient::new(config.remote_base_url.clone())?);
let cache = cache::new_cache();
let cleanup = cache::spawn_cleanup_task(cache.clone());
let app_state = server::AppState {
config: config.clone(),
cache,
remote,
};
let app = server::router(app_state);
tracing::info!(
bind = %config.bind,
remote = %config.remote_base_url,
"secrets-mcp-local service started"
);
let listener = tokio::net::TcpListener::bind(config.bind)
.await
.with_context(|| format!("failed to bind {}", config.bind))?;
let result = axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.context("server error");
cleanup.abort();
result
}

View File

@@ -1,828 +0,0 @@
use std::convert::Infallible;
use std::time::Instant;
use axum::body::Body;
use axum::extract::State;
use axum::http::{StatusCode, header};
use axum::response::Response;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::bind::{exchange_bind, start_bind};
use crate::cache::{ExecContext, GatewayPhase};
use crate::exec::{TargetExecInput, execute_command};
use crate::server::AppState;
use crate::target::{TargetSnapshot, build_execution_target};
use crate::unlock::status_payload;
const LOCAL_EXEC_TOOL: &str = "target_exec";
#[derive(Deserialize, Default)]
struct BindExchangeArgs {
bind_id: Option<String>,
device_code: Option<String>,
}
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()))
.unwrap()
}
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 empty_notification_response() -> Response {
Response::builder()
.status(StatusCode::ACCEPTED)
.body(Body::empty())
.unwrap()
}
fn method_not_found(id: Value, method: &str) -> Response {
json_response(
StatusCode::OK,
json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": format!("method `{method}` not supported by secrets-mcp-local"),
}
}),
)
}
fn invalid_request_response(message: impl Into<String>) -> Response {
json_response(
StatusCode::BAD_REQUEST,
json!({
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32600,
"message": message.into(),
}
}),
)
}
fn status_tool_definitions() -> Vec<Value> {
vec![
json!({
"name": "local_status",
"description": "Read the local gateway readiness state, onboarding URL, unlock URL, and any pending approval session.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "title": "Local MCP Status" }
}),
json!({
"name": "local_unlock_status",
"description": "Return whether the local gateway is waiting for passphrase unlock or already ready.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "title": "Local Unlock Status" }
}),
json!({
"name": "local_onboarding_info",
"description": "Return the local onboarding page URL, MCP URL, and current next-step guidance for the user.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "title": "Local Onboarding Info" }
}),
]
}
fn bind_tool_definitions() -> Vec<Value> {
vec![
json!({
"name": "local_bind_start",
"description": "Start a new remote authorization session and return the approve_url that the user should open in a browser.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "title": "Start Local MCP Binding" }
}),
json!({
"name": "local_bind_exchange",
"description": "Poll the current bind session. When the user has approved in the browser, this moves the gateway into pendingUnlock and returns the local unlock URL.",
"inputSchema": {
"type": "object",
"properties": {
"bind_id": { "type": ["string", "null"] },
"device_code": { "type": ["string", "null"] }
}
},
"annotations": { "title": "Poll Binding State" }
}),
]
}
fn ready_tool_definitions() -> Vec<Value> {
vec![
json!({
"name": "secrets_find",
"description": "Find entries in the secrets store and return target snapshots suitable for target_exec.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": ["string", "null"] },
"metadata_query": { "type": ["string", "null"] },
"folder": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] },
"name": { "type": ["string", "null"] },
"name_query": { "type": ["string", "null"] },
"tags": { "type": ["array", "null"], "items": { "type": "string" } },
"limit": { "type": ["integer", "null"] },
"offset": { "type": ["integer", "null"] }
}
},
"annotations": { "title": "Find Secrets" }
}),
json!({
"name": "secrets_search",
"description": "Search entries with optional summary mode. Returns metadata and secret field names, not secret values.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": ["string", "null"] },
"metadata_query": { "type": ["string", "null"] },
"folder": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] },
"name": { "type": ["string", "null"] },
"name_query": { "type": ["string", "null"] },
"tags": { "type": ["array", "null"], "items": { "type": "string" } },
"summary": { "type": ["boolean", "null"] },
"sort": { "type": ["string", "null"] },
"limit": { "type": ["integer", "null"] },
"offset": { "type": ["integer", "null"] }
}
},
"annotations": { "title": "Search Secrets" }
}),
json!({
"name": "secrets_history",
"description": "View change history for an entry by id or by name/folder.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": ["string", "null"] },
"name": { "type": ["string", "null"] },
"folder": { "type": ["string", "null"] },
"limit": { "type": ["integer", "null"] }
}
},
"annotations": { "title": "View Secret History" }
}),
json!({
"name": "secrets_overview",
"description": "Get counts of entries per folder and per type.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "title": "Secrets Overview" }
}),
json!({
"name": "secrets_delete",
"description": "Preview deletions only. dry_run must be true.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": ["string", "null"] },
"name": { "type": ["string", "null"] },
"folder": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] },
"dry_run": { "type": ["boolean", "null"] }
}
},
"annotations": { "title": "Delete Secret Entry Preview", "destructiveHint": true }
}),
json!({
"name": LOCAL_EXEC_TOOL,
"description": "Execute a standard local command against a resolved secrets target. The local gateway injects target metadata and secret values as environment variables without exposing raw secret values to the AI.",
"inputSchema": {
"type": "object",
"properties": {
"target_ref": {
"type": ["string", "null"],
"description": "Target entry id from secrets_find/secrets_search. Required on first use; later calls may reuse the cached execution context for the same entry id."
},
"target": {
"type": ["object", "null"],
"description": "Optional target snapshot copied from secrets_find/secrets_search. Required on first use when the local gateway has not cached this entry id yet."
},
"command": {
"type": "string",
"description": "Standard shell command to execute locally, such as ssh/curl/docker/http."
},
"timeout_secs": {
"type": ["integer", "null"],
"description": "Execution timeout in seconds."
},
"working_dir": {
"type": ["string", "null"],
"description": "Optional working directory for the command."
},
"env_overrides": {
"type": ["object", "null"],
"description": "Optional extra environment variables. Reserved TARGET_* names cannot be overridden."
}
},
"required": ["command"]
},
"annotations": { "title": "Execute Against Target" }
}),
]
}
fn tools_for_phase(phase: GatewayPhase) -> Vec<Value> {
let mut tools = status_tool_definitions();
if phase != GatewayPhase::Ready {
tools.extend(bind_tool_definitions());
}
if phase == GatewayPhase::Ready {
tools.extend(ready_tool_definitions());
}
tools
}
async fn current_phase_and_status(state: &AppState) -> (GatewayPhase, Value) {
let payload = status_payload(state).await;
let phase = payload
.get("state")
.cloned()
.and_then(|value| serde_json::from_value(value).ok())
.unwrap_or(GatewayPhase::Bootstrap);
(phase, payload)
}
fn instructions_for_phase(phase: GatewayPhase) -> &'static str {
match phase {
GatewayPhase::Bootstrap => {
"Use local_status and local_bind_start first. The user must open the approve_url in a browser before the local gateway can continue."
}
GatewayPhase::PendingUnlock => {
"Remote authorization is complete. Ask the user to open the local unlock page and finish passphrase unlock before calling business tools."
}
GatewayPhase::Ready => {
"The local gateway is ready. Use secrets_find/secrets_search for discovery and target_exec for delegated command execution against decrypted targets."
}
}
}
fn initialize_response(id: Value, phase: GatewayPhase) -> Response {
let session_id = format!(
"local-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
);
let response = json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "secrets-mcp-local",
"version": env!("CARGO_PKG_VERSION"),
"title": "Secrets MCP Local"
},
"instructions": instructions_for_phase(phase),
}
});
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.header("mcp-session-id", session_id)
.body(Body::from(response.to_string()))
.unwrap()
}
async fn resolve_target_context(
state: &AppState,
api_key: &str,
unlock_key: &str,
unlock_expires_at: Instant,
input: &TargetExecInput,
) -> anyhow::Result<crate::target::ExecutionTarget> {
let target_ref = input
.target_ref
.clone()
.or_else(|| input.target.as_ref().map(|t| t.id.clone()))
.ok_or_else(|| anyhow::anyhow!("target_ref is required"))?;
{
let mut guard = state.cache.write().await;
if let Some(ctx) = guard.exec_contexts.get_mut(&target_ref)
&& ctx.expires_at > Instant::now()
{
ctx.last_used_at = Instant::now();
return Ok(ctx.target.clone());
}
}
let snapshot: TargetSnapshot = input.target.clone().ok_or_else(|| {
anyhow::anyhow!(
"target details required on first use for entry `{target_ref}`; pass the matching secrets_find/search result as `target`"
)
})?;
if snapshot.id != target_ref {
return Err(anyhow::anyhow!(
"target_ref `{target_ref}` does not match target.id `{}`",
snapshot.id
));
}
let secrets = state
.remote
.get_entry_secrets_by_id(api_key, unlock_key, &target_ref)
.await?;
let target = build_execution_target(&snapshot, &secrets)?;
let expires_at = std::cmp::min(
Instant::now() + state.config.default_exec_context_ttl,
unlock_expires_at,
);
let mut guard = state.cache.write().await;
guard.exec_contexts.insert(
target_ref,
ExecContext {
target: target.clone(),
expires_at,
last_used_at: Instant::now(),
},
);
Ok(target)
}
async fn handle_target_exec(state: &AppState, id: Value, args: Option<Value>) -> Response {
let input: TargetExecInput = match args {
Some(value) => match serde_json::from_value(value) {
Ok(input) => input,
Err(err) => {
return tool_error_response(id, format!("invalid `{LOCAL_EXEC_TOOL}` args: {err}"));
}
},
None => {
return tool_error_response(id, format!("`{LOCAL_EXEC_TOOL}` arguments are required"));
}
};
if input.command.trim().is_empty() {
return tool_error_response(id, "command is required");
}
let api_key = {
let guard = state.cache.read().await;
match guard.bound.as_ref() {
Some(bound) => bound.api_key.clone(),
None => {
return tool_error_response(
id,
"local MCP is not bound; call local_bind_start first",
);
}
}
};
let (unlock_key, unlock_expires_at) = {
let mut guard = state.cache.write().await;
match guard.unlock.as_mut() {
Some(unlock) if unlock.expires_at > Instant::now() => {
unlock.last_used_at = Instant::now();
(unlock.encryption_key_hex.clone(), unlock.expires_at)
}
_ => {
guard.clear_unlock_and_exec();
return tool_error_response(
id,
"local MCP is not unlocked; ask the user to open the local unlock page first",
);
}
}
};
let target =
match resolve_target_context(state, &api_key, &unlock_key, unlock_expires_at, &input).await
{
Ok(target) => target,
Err(err) => return tool_error_response(id, format!("failed resolving target: {err}")),
};
let timeout_secs = input.timeout_secs.unwrap_or(120).clamp(1, 3600);
let result = match execute_command(&input, &target, timeout_secs).await {
Ok(result) => result,
Err(err) => return tool_error_response(id, format!("execution failed: {err}")),
};
tool_success_response(
id,
serde_json::to_value(result).unwrap_or_else(|_| json!({})),
)
}
async fn handle_bootstrap_tool(
state: &AppState,
tool_name: &str,
id: Value,
args: Option<Value>,
) -> Response {
match tool_name {
"local_status" | "local_unlock_status" | "local_onboarding_info" => {
tool_success_response(id, status_payload(state).await)
}
"local_bind_start" => match start_bind(state).await {
Ok(payload) => tool_success_response(id, payload),
Err((_status, message)) => tool_error_response(id, message),
},
"local_bind_exchange" => {
let parsed = match args {
Some(value) => match serde_json::from_value::<BindExchangeArgs>(value) {
Ok(parsed) => parsed,
Err(err) => {
return tool_error_response(
id,
format!("invalid local_bind_exchange args: {err}"),
);
}
},
None => BindExchangeArgs::default(),
};
match exchange_bind(state, parsed.bind_id, parsed.device_code).await {
Ok((_status, payload)) => tool_success_response(id, payload),
Err((_status, message)) => tool_error_response(id, message),
}
}
_ => tool_error_response(id, format!("unknown bootstrap tool `{tool_name}`")),
}
}
fn bootstrap_tool_allowed_in_phase(tool_name: &str, phase: GatewayPhase) -> bool {
is_status_tool(tool_name) || (phase != GatewayPhase::Ready && is_bind_tool(tool_name))
}
fn is_status_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"local_status" | "local_unlock_status" | "local_onboarding_info"
)
}
fn is_bind_tool(tool_name: &str) -> bool {
matches!(tool_name, "local_bind_start" | "local_bind_exchange")
}
fn is_bootstrap_tool(tool_name: &str) -> bool {
is_status_tool(tool_name) || is_bind_tool(tool_name)
}
fn is_ready_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"secrets_find"
| "secrets_search"
| "secrets_history"
| "secrets_overview"
| "secrets_delete"
| LOCAL_EXEC_TOOL
)
}
fn not_ready_message(status: &Value) -> String {
let onboarding_url = status
.get("onboarding_url")
.and_then(|v| v.as_str())
.unwrap_or("/");
let state_name = status
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("bootstrap");
format!(
"local MCP is not ready (state: {state_name}). Use local_status/local_bind_start first and ask the user to complete onboarding at {onboarding_url}"
)
}
async fn handle_ready_tool(
state: &AppState,
tool_name: &str,
id: Value,
args: Option<Value>,
) -> Response {
let api_key = {
let guard = state.cache.read().await;
match guard.bound.as_ref() {
Some(bound) => bound.api_key.clone(),
None => return tool_error_response(id, "local MCP is not bound"),
}
};
let args_value = args.unwrap_or_else(|| json!({}));
let result = match tool_name {
"secrets_find" => state.remote.entries_find(&api_key, &args_value).await,
"secrets_search" => state.remote.entries_search(&api_key, &args_value).await,
"secrets_history" => state.remote.entry_history(&api_key, &args_value).await,
"secrets_overview" => state.remote.entries_overview(&api_key).await,
"secrets_delete" => {
if args_value.get("dry_run").and_then(|value| value.as_bool()) != Some(true) {
return tool_error_response(
id,
"secrets_delete is exposed in local mode only for dry_run=true previews",
);
}
state.remote.delete_preview(&api_key, &args_value).await
}
LOCAL_EXEC_TOOL => return handle_target_exec(state, id, Some(args_value)).await,
_ => return tool_error_response(id, format!("unknown ready tool `{tool_name}`")),
};
match result {
Ok(value) => tool_success_response(id, value),
Err(err) => tool_error_response(id, err.to_string()),
}
}
pub async fn handle_mcp(State(state): State<AppState>, body: Body) -> Result<Response, Infallible> {
let body_bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await {
Ok(bytes) => bytes,
Err(_) => return Ok(invalid_request_response("invalid request body")),
};
let request: Value = match serde_json::from_slice(&body_bytes) {
Ok(request) => request,
Err(err) => {
return Ok(invalid_request_response(format!(
"invalid json body: {err}"
)));
}
};
let method = request
.get("method")
.and_then(|value| value.as_str())
.unwrap_or_default();
let id = request.get("id").cloned().unwrap_or(json!(null));
let (phase, status) = current_phase_and_status(&state).await;
let response = match method {
"initialize" => initialize_response(id, phase),
"notifications/initialized" => empty_notification_response(),
"tools/list" => jsonrpc_result_response(id, json!({ "tools": tools_for_phase(phase) })),
"tools/call" => {
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
let tool_name = params
.get("name")
.and_then(|value| value.as_str())
.unwrap_or_default();
let args = params.get("arguments").cloned();
if is_bootstrap_tool(tool_name) {
if !bootstrap_tool_allowed_in_phase(tool_name, phase) {
tool_error_response(
id,
"local MCP is already ready; binding tools are disabled until you explicitly unbind",
)
} else {
handle_bootstrap_tool(&state, tool_name, id, args).await
}
} else if phase != GatewayPhase::Ready {
tool_error_response(id, not_ready_message(&status))
} else if is_ready_tool(tool_name) {
handle_ready_tool(&state, tool_name, id, args).await
} else {
tool_error_response(
id,
format!("tool `{tool_name}` is not exposed by local policy"),
)
}
}
"ping" => jsonrpc_result_response(id, json!({})),
_ => method_not_found(id, method),
};
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::{BoundState, UnlockState, new_cache};
use crate::config::LocalConfig;
use crate::remote::RemoteClient;
use crate::server::AppState;
use std::sync::Arc;
use std::time::Duration;
use url::Url;
use uuid::Uuid;
fn test_state() -> AppState {
AppState {
config: LocalConfig {
bind: "127.0.0.1:9316".parse().unwrap(),
remote_base_url: Url::parse("https://example.com").unwrap(),
default_unlock_ttl: Duration::from_secs(3600),
default_exec_context_ttl: Duration::from_secs(3600),
},
cache: new_cache(),
remote: Arc::new(
RemoteClient::new(Url::parse("https://example.com").unwrap()).unwrap(),
),
}
}
#[test]
fn bootstrap_phase_hides_ready_tools() {
let tools = tools_for_phase(GatewayPhase::Bootstrap);
let names: Vec<_> = tools
.iter()
.filter_map(|tool| tool.get("name").and_then(|value| value.as_str()))
.collect();
assert!(names.contains(&"local_status"));
assert!(names.contains(&"local_bind_start"));
assert!(!names.contains(&"secrets_find"));
assert!(!names.contains(&LOCAL_EXEC_TOOL));
}
#[tokio::test]
async fn initialize_succeeds_when_unbound() {
let response = handle_mcp(
State(test_state()),
Body::from(
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
})
.to_string(),
),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn tools_list_returns_bootstrap_tools_when_unbound() {
let response = handle_mcp(
State(test_state()),
Body::from(
json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
})
.to_string(),
),
)
.await
.unwrap();
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
let names: Vec<_> = value["result"]["tools"]
.as_array()
.unwrap()
.iter()
.filter_map(|tool| tool.get("name").and_then(|name| name.as_str()))
.collect();
assert!(names.contains(&"local_status"));
assert!(names.contains(&"local_bind_exchange"));
assert!(!names.contains(&"secrets_find"));
}
#[tokio::test]
async fn tools_list_in_ready_phase_exposes_business_tools() {
let state = test_state();
{
let mut guard = state.cache.write().await;
guard.bound = Some(BoundState {
user_id: Uuid::nil(),
api_key: "api-key".to_string(),
key_salt_hex: None,
key_check_hex: None,
key_params: None,
key_version: 0,
bound_at: Instant::now(),
});
guard.unlock = Some(UnlockState {
encryption_key_hex: "11".repeat(32),
expires_at: Instant::now() + Duration::from_secs(600),
last_used_at: Instant::now(),
});
}
let response = handle_mcp(
State(state),
Body::from(
json!({
"jsonrpc": "2.0",
"id": 3,
"method": "tools/list",
"params": {}
})
.to_string(),
),
)
.await
.unwrap();
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
let names: Vec<_> = value["result"]["tools"]
.as_array()
.unwrap()
.iter()
.filter_map(|tool| tool.get("name").and_then(|name| name.as_str()))
.collect();
assert!(names.contains(&"local_status"));
assert!(names.contains(&"secrets_find"));
assert!(names.contains(&LOCAL_EXEC_TOOL));
assert!(!names.contains(&"local_bind_start"));
}
#[tokio::test]
async fn tools_call_rejects_bind_start_when_ready() {
let state = test_state();
{
let mut guard = state.cache.write().await;
guard.bound = Some(BoundState {
user_id: Uuid::nil(),
api_key: "api-key".to_string(),
key_salt_hex: None,
key_check_hex: None,
key_params: None,
key_version: 0,
bound_at: Instant::now(),
});
guard.unlock = Some(UnlockState {
encryption_key_hex: "11".repeat(32),
expires_at: Instant::now() + Duration::from_secs(600),
last_used_at: Instant::now(),
});
}
let response = handle_mcp(
State(state),
Body::from(
json!({
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "local_bind_start",
"arguments": {}
}
})
.to_string(),
),
)
.await
.unwrap();
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["result"]["isError"], Value::Bool(true));
assert!(value.get("error").is_none());
}
#[tokio::test]
async fn tool_error_response_uses_mcp_tool_result_shape() {
let response = tool_error_response(json!(9), "boom");
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["id"], json!(9));
assert_eq!(value["result"]["isError"], Value::Bool(true));
assert_eq!(value["result"]["content"][0]["text"], json!("boom"));
assert!(value.get("error").is_none());
}
}

View File

@@ -1,263 +0,0 @@
use std::collections::HashMap;
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
use uuid::Uuid;
#[derive(Clone)]
pub struct RemoteClient {
pub http_client: reqwest::Client,
pub remote_base_url: Url,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct BindStartResponse {
pub bind_id: String,
pub device_code: String,
pub approve_url: String,
pub expires_in_secs: u64,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct BindExchangeResponse {
pub status: Option<String>,
pub user_id: Option<Uuid>,
pub api_key: Option<String>,
pub key_salt_hex: Option<String>,
pub key_check_hex: Option<String>,
pub key_params: Option<Value>,
pub key_version: Option<i64>,
}
#[derive(Debug)]
pub struct BindExchangeResult {
pub status: u16,
pub body: Value,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct BindRefreshResponse {
pub user_id: Uuid,
pub key_salt_hex: Option<String>,
pub key_check_hex: Option<String>,
pub key_params: Option<Value>,
pub key_version: i64,
}
#[derive(Debug)]
pub struct BindRefreshResult {
pub status: u16,
pub body: Option<BindRefreshResponse>,
}
impl RemoteClient {
pub fn new(remote_base_url: Url) -> Result<Self> {
let http_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.context("failed to build HTTP client")?;
Ok(Self {
http_client,
remote_base_url,
})
}
fn authed_request(
&self,
method: reqwest::Method,
path: &str,
api_key: &str,
encryption_key_hex: Option<&str>,
) -> reqwest::RequestBuilder {
let mut url = self.remote_base_url.clone();
url.set_path(path);
let mut req = self
.http_client
.request(method, url.as_str())
.bearer_auth(api_key)
.header(reqwest::header::ACCEPT, "application/json");
if let Some(key) = encryption_key_hex {
req = req.header("X-Encryption-Key", key);
}
req
}
async fn parse_json_response(
&self,
res: reqwest::Response,
label: &str,
) -> Result<serde_json::Value> {
let status = res.status();
let bytes = res
.bytes()
.await
.with_context(|| format!("{label} body read failed"))?;
let value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| {
Value::String(String::from_utf8_lossy(&bytes).trim().to_string())
})
};
if !status.is_success() {
let message = value
.get("error")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
.unwrap_or_else(|| value.to_string());
return Err(anyhow!("{label} failed ({}): {message}", status));
}
Ok(value)
}
pub async fn bind_start(&self) -> Result<BindStartResponse> {
let mut url = self.remote_base_url.clone();
url.set_path("/api/local-mcp/bind/start");
let res = self
.http_client
.post(url.as_str())
.send()
.await
.context("bind/start request failed")?;
if !res.status().is_success() {
return Err(anyhow!("bind/start failed: {}", res.status()));
}
res.json::<BindStartResponse>()
.await
.context("invalid bind/start response")
}
pub async fn bind_exchange(
&self,
bind_id: &str,
device_code: &str,
) -> Result<BindExchangeResult> {
let mut url = self.remote_base_url.clone();
url.set_path("/api/local-mcp/bind/exchange");
let res = self
.http_client
.post(url.as_str())
.json(&serde_json::json!({
"bind_id": bind_id,
"device_code": device_code,
}))
.send()
.await
.context("bind/exchange request failed")?;
let status = res.status().as_u16();
let bytes = res
.bytes()
.await
.context("bind/exchange body read failed")?;
let body = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| {
Value::String(String::from_utf8_lossy(&bytes).trim().to_string())
})
};
Ok(BindExchangeResult { status, body })
}
pub async fn bind_refresh(&self, api_key: &str) -> Result<BindRefreshResult> {
let mut url = self.remote_base_url.clone();
url.set_path("/api/local-mcp/bind/refresh");
let res = self
.http_client
.post(url.as_str())
.header(
axum::http::header::AUTHORIZATION,
format!("Bearer {api_key}"),
)
.send()
.await
.context("bind/refresh request failed")?;
let status = res.status().as_u16();
if !res.status().is_success() {
return Ok(BindRefreshResult { status, body: None });
}
let body = res
.json::<BindRefreshResponse>()
.await
.context("invalid bind/refresh response")?;
Ok(BindRefreshResult {
status,
body: Some(body),
})
}
async fn post_api_json(
&self,
api_key: &str,
encryption_key_hex: Option<&str>,
path: &str,
body: &Value,
) -> Result<Value> {
let res = self
.authed_request(reqwest::Method::POST, path, api_key, encryption_key_hex)
.json(body)
.send()
.await
.with_context(|| format!("{path} request failed"))?;
self.parse_json_response(res, path).await
}
async fn get_api_json(
&self,
api_key: &str,
encryption_key_hex: Option<&str>,
path: &str,
) -> Result<reqwest::Response> {
let req = self.authed_request(reqwest::Method::GET, path, api_key, encryption_key_hex);
let res = req
.send()
.await
.with_context(|| format!("{path} request failed"))?;
Ok(res)
}
pub async fn entries_find(&self, api_key: &str, args: &Value) -> Result<Value> {
self.post_api_json(api_key, None, "/api/local-mcp/entries/find", args)
.await
}
pub async fn entries_search(&self, api_key: &str, args: &Value) -> Result<Value> {
self.post_api_json(api_key, None, "/api/local-mcp/entries/search", args)
.await
}
pub async fn entry_history(&self, api_key: &str, args: &Value) -> Result<Value> {
self.post_api_json(api_key, None, "/api/local-mcp/entries/history", args)
.await
}
pub async fn entries_overview(&self, api_key: &str) -> Result<Value> {
let res = self
.get_api_json(api_key, None, "/api/local-mcp/entries/overview")
.await?;
self.parse_json_response(res, "/api/local-mcp/entries/overview")
.await
}
pub async fn delete_preview(&self, api_key: &str, args: &Value) -> Result<Value> {
self.post_api_json(api_key, None, "/api/local-mcp/entries/delete-preview", args)
.await
}
pub async fn get_entry_secrets_by_id(
&self,
api_key: &str,
encryption_key_hex: &str,
entry_id: &str,
) -> Result<HashMap<String, Value>> {
let path = format!("/api/local-mcp/entries/{entry_id}/secrets");
let res = self
.get_api_json(api_key, Some(encryption_key_hex), &path)
.await?;
let value = self.parse_json_response(res, &path).await?;
serde_json::from_value::<HashMap<String, Value>>(value)
.context("invalid decrypt payload from remote HTTP API")
}
}

View File

@@ -1,157 +0,0 @@
use std::sync::Arc;
use axum::Router;
use axum::extract::State;
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use crate::cache::SharedCache;
use crate::config::LocalConfig;
use crate::remote::RemoteClient;
#[derive(Clone)]
pub struct AppState {
pub config: LocalConfig,
pub cache: SharedCache,
pub remote: Arc<RemoteClient>,
}
async fn index(State(state): State<AppState>) -> impl IntoResponse {
Html(format!(
r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>secrets-mcp-local onboarding</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 920px; margin: 24px auto; padding: 0 16px; line-height: 1.5; }}
code, pre {{ background: #f6f8fa; border-radius: 6px; }}
code {{ padding: 2px 6px; }}
pre {{ padding: 12px; overflow-x: auto; }}
.card {{ border: 1px solid #d0d7de; border-radius: 12px; padding: 16px; margin: 16px 0; }}
.row {{ display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }}
button, a.button {{ border: 1px solid #1f2328; background: #1f2328; color: white; padding: 8px 14px; border-radius: 8px; text-decoration: none; cursor: pointer; }}
a.secondary, button.secondary {{ background: white; color: #1f2328; }}
iframe {{ width: 100%; min-height: 420px; border: 1px solid #d0d7de; border-radius: 12px; }}
.muted {{ color: #57606a; }}
</style>
</head>
<body>
<h1>secrets-mcp-local</h1>
<p class="muted">本地 MCP 地址:<code>http://{bind}/mcp</code></p>
<p class="muted">远端服务地址:<code>{remote}</code></p>
<div class="card">
<h2>当前状态</h2>
<pre id="status">loading...</pre>
<div class="row">
<button id="start-bind">开始绑定</button>
<button id="poll-bind" class="secondary">检查授权结果</button>
<a class="button secondary" href="/unlock" target="_blank" rel="noreferrer">打开解锁页</a>
<button id="refresh" class="secondary">刷新状态</button>
</div>
</div>
<div class="card">
<h2>步骤 1远端授权</h2>
<p id="approve-hint" class="muted">点击“开始绑定”后,这里会显示授权地址。</p>
<div id="approve-actions" class="row"></div>
</div>
<div class="card">
<h2>步骤 2本地解锁</h2>
<p class="muted">授权完成后,本页会自动切换到解锁阶段。你也可以直接在下方完成解锁。</p>
<iframe id="unlock-frame" src="/unlock"></iframe>
</div>
<div class="card">
<h2>接入 Cursor</h2>
<p>把 MCP 地址配置为 <code>http://{bind}/mcp</code>。在未就绪时AI 只会看到 bootstrap 工具;完成授权和解锁后会自动暴露业务工具。</p>
</div>
<script>
const statusEl = document.getElementById('status');
const approveHint = document.getElementById('approve-hint');
const approveActions = document.getElementById('approve-actions');
const unlockFrame = document.getElementById('unlock-frame');
function renderApprove(info) {{
approveActions.innerHTML = '';
if (!info?.approve_url) return;
approveHint.textContent = '请先在浏览器完成远端授权,然后回到这里等待自动进入解锁状态。';
const link = document.createElement('a');
link.href = info.approve_url;
link.target = '_blank';
link.rel = 'noreferrer';
link.className = 'button';
link.textContent = '打开远端授权页';
approveActions.appendChild(link);
}}
async function refreshStatus() {{
const res = await fetch('/local/status');
const data = await res.json();
statusEl.textContent = JSON.stringify(data, null, 2);
if (data.pending_bind) renderApprove(data.pending_bind);
if (data.state === 'ready') {{
approveHint.textContent = '本地 MCP 已 ready可以返回 Cursor 正常使用。';
}} else if (data.state === 'pendingUnlock') {{
approveHint.textContent = '远端授权已完成,继续在下方完成本地解锁。';
}}
return data;
}}
async function startBind() {{
const res = await fetch('/local/bind/start', {{ method: 'POST' }});
const data = await res.json();
statusEl.textContent = JSON.stringify(data, null, 2);
renderApprove(data);
}}
async function pollBind() {{
const res = await fetch('/local/bind/exchange', {{
method: 'POST',
headers: {{ 'content-type': 'application/json' }},
body: JSON.stringify({{}})
}});
const data = await res.json();
statusEl.textContent = JSON.stringify(data, null, 2);
await refreshStatus();
if (res.ok && data.status === 'bound') {{
unlockFrame.src = '/unlock';
}}
}}
document.getElementById('start-bind').onclick = startBind;
document.getElementById('poll-bind').onclick = pollBind;
document.getElementById('refresh').onclick = refreshStatus;
window.addEventListener('message', (event) => {{
if (event?.data?.type === 'secrets-mcp-local-ready') refreshStatus();
}});
refreshStatus();
setInterval(refreshStatus, 3000);
</script>
</body>
</html>"#,
bind = state.config.bind,
remote = state.config.remote_base_url,
))
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(index))
.route("/mcp", axum::routing::any(crate::mcp::handle_mcp))
.route("/local/bind/start", post(crate::bind::bind_start))
.route("/local/bind/exchange", post(crate::bind::bind_exchange))
.route("/local/unbind", post(crate::bind::unbind))
.route("/unlock", get(crate::unlock::unlock_page))
.route(
"/local/unlock/complete",
post(crate::unlock::unlock_complete),
)
.route("/local/lock", post(crate::unlock::lock))
.route("/local/status", get(crate::unlock::status))
.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
.with_state(state)
}

View File

@@ -1,265 +0,0 @@
use std::time::Instant;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse};
use secrets_core::crypto::{decrypt, extract_key_from_hex, hex};
use serde::Deserialize;
use serde_json::json;
use crate::bind::refresh_bound_state;
use crate::cache::UnlockState;
use crate::server::AppState;
const KEY_CHECK_PLAINTEXT: &[u8] = b"secrets-mcp-key-check";
fn verify_key_check_hex(key_hex: &str, key_check_hex: &str) -> Result<(), (StatusCode, String)> {
let key_check = hex::decode_hex(key_check_hex).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("invalid key_check hex: {e}"),
)
})?;
let user_key = extract_key_from_hex(key_hex).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("invalid encryption key: {e}"),
)
})?;
let plaintext = decrypt(&user_key, &key_check)
.map_err(|_| (StatusCode::UNAUTHORIZED, "wrong passphrase".to_string()))?;
if plaintext != KEY_CHECK_PLAINTEXT {
return Err((StatusCode::UNAUTHORIZED, "wrong passphrase".to_string()));
}
Ok(())
}
#[derive(Deserialize)]
pub struct UnlockCompleteBody {
encryption_key: String,
ttl_secs: Option<u64>,
}
pub async fn unlock_page(State(state): State<AppState>) -> impl IntoResponse {
refresh_bound_state(&state).await;
let bound = {
let guard = state.cache.read().await;
guard.bound.clone()
};
let Some(mut bound) = bound else {
return Html(
"<h1>Not bound</h1><p>Run /local/bind/start and complete approve first.</p>"
.to_string(),
);
};
{
let guard = state.cache.read().await;
if let Some(updated) = guard.bound.clone() {
bound = updated;
}
}
let key_salt_hex = bound.key_salt_hex.as_deref().unwrap_or("");
let key_check_hex = bound.key_check_hex.as_deref().unwrap_or("");
let iterations = bound
.key_params
.as_ref()
.and_then(|v| v.get("iterations"))
.and_then(|n| n.as_u64())
.unwrap_or(600_000);
Html(format!(
r#"<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Local MCP Unlock</title></head>
<body>
<h1>解锁本地 MCP</h1>
<p>用户:<code>{user_id}</code></p>
<label>Passphrase: <input id="pp" type="password" autocomplete="off"/></label>
<label>TTL(sec): <input id="ttl" type="number" value="{ttl}" min="60" max="604800"/></label>
<button id="go">Derive and Unlock</button>
<pre id="out"></pre>
<script>
const SALT_HEX = "{salt}";
const KEY_CHECK_HEX = "{key_check}";
const ITER = {iter};
function notifyParentReady() {{
try {{
window.parent?.postMessage({{type:'secrets-mcp-local-ready'}}, '*');
}} catch (_err) {{}}
}}
function hexToBytes(hex) {{
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i*2,2), 16);
return out;
}}
function bytesToHex(bytes) {{
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join('');
}}
async function verifyKeyCheck(hexKey) {{
const keyBytes = hexToBytes(hexKey);
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, {{name:'AES-GCM'}}, false, ['decrypt']);
const payload = hexToBytes(KEY_CHECK_HEX);
const nonce = payload.slice(0, 12);
const ciphertext = payload.slice(12);
try {{
const plain = await crypto.subtle.decrypt({{name:'AES-GCM', iv: nonce}}, cryptoKey, ciphertext);
return new TextDecoder().decode(plain) === 'secrets-mcp-key-check';
}} catch {{
return false;
}}
}}
document.getElementById('go').onclick = async () => {{
const pp = document.getElementById('pp').value;
const ttl = Number(document.getElementById('ttl').value || {ttl});
const out = document.getElementById('out');
if (!SALT_HEX) {{ out.textContent = 'key_salt missing; set passphrase on remote first'; return; }}
if (!KEY_CHECK_HEX) {{ out.textContent = 'key_check missing; refresh bind first'; return; }}
if (!pp) {{ out.textContent = 'passphrase required'; return; }}
out.textContent = 'deriving...';
try {{
const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(pp), {{name:'PBKDF2'}}, false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({{name:'PBKDF2', salt: hexToBytes(SALT_HEX), iterations: ITER, hash: 'SHA-256'}}, keyMat, 256);
const hex = bytesToHex(new Uint8Array(bits));
const valid = await verifyKeyCheck(hex);
if (!valid) {{ out.textContent = 'wrong passphrase'; return; }}
const res = await fetch('/local/unlock/complete', {{
method:'POST', headers:{{'content-type':'application/json'}},
body: JSON.stringify({{encryption_key: hex, ttl_secs: ttl}})
}});
const txt = await res.text();
out.textContent = txt;
if (res.ok) notifyParentReady();
}} catch (e) {{
out.textContent = String(e);
}}
}};
</script>
</body>
</html>"#,
user_id = bound.user_id,
ttl = state.config.default_unlock_ttl.as_secs(),
salt = key_salt_hex,
key_check = key_check_hex,
iter = iterations
))
}
pub async fn unlock_complete(
State(state): State<AppState>,
axum::Json(input): axum::Json<UnlockCompleteBody>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let key = input.encryption_key.trim();
if key.len() != 64 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
return Err((
StatusCode::BAD_REQUEST,
"encryption_key must be 64 hex chars".to_string(),
));
}
let ttl = std::time::Duration::from_secs(
input
.ttl_secs
.unwrap_or(state.config.default_unlock_ttl.as_secs())
.clamp(60, 86400 * 7),
);
let mut guard = state.cache.write().await;
let Some(bound) = guard.bound.as_ref() else {
return Err((StatusCode::UNAUTHORIZED, "not bound".to_string()));
};
let key_check_hex = bound
.key_check_hex
.as_deref()
.ok_or((StatusCode::BAD_REQUEST, "key_check missing".to_string()))?;
verify_key_check_hex(key, key_check_hex)?;
guard.exec_contexts.clear();
guard.unlock = Some(UnlockState {
encryption_key_hex: key.to_string(),
expires_at: Instant::now() + ttl,
last_used_at: Instant::now(),
});
Ok((
StatusCode::OK,
axum::Json(json!({"ok": true, "ttl_secs": ttl.as_secs()})),
))
}
pub async fn lock(State(state): State<AppState>) -> impl IntoResponse {
let mut guard = state.cache.write().await;
guard.clear_unlock_and_exec();
(StatusCode::OK, axum::Json(json!({"ok": true})))
}
pub async fn status(State(state): State<AppState>) -> impl IntoResponse {
let payload = status_payload(&state).await;
(StatusCode::OK, axum::Json(payload))
}
pub async fn status_payload(state: &AppState) -> serde_json::Value {
refresh_bound_state(state).await;
let now = Instant::now();
let mut guard = state.cache.write().await;
let unlocked = guard
.unlock
.as_ref()
.is_some_and(|u| u.expires_at > now && !u.encryption_key_hex.is_empty());
let expires_in_secs = guard
.unlock
.as_ref()
.and_then(|u| (u.expires_at > now).then_some(u.expires_at.duration_since(now).as_secs()));
if guard.unlock.as_ref().is_some_and(|u| u.expires_at <= now) {
guard.clear_unlock_and_exec();
}
let state_name = guard.phase(now);
let bound = guard.bound.as_ref().map(|b| {
json!({
"user_id": b.user_id,
"key_version": b.key_version,
"bound_for_secs": b.bound_at.elapsed().as_secs(),
})
});
let pending_bind = guard.pending_bind.as_ref().map(|pending| {
json!({
"bind_id": pending.bind_id,
"device_code": pending.device_code,
"approve_url": pending.approve_url,
"expires_in_secs": pending.expires_at.saturating_duration_since(now).as_secs(),
"started_for_secs": pending.started_at.elapsed().as_secs(),
})
});
json!({
"state": state_name,
"bound": bound,
"pending_bind": pending_bind,
"unlocked": unlocked,
"expires_in_secs": expires_in_secs,
"cached_targets": guard.exec_contexts.len(),
"onboarding_url": format!("http://{}/", state.config.bind),
"unlock_url": format!("http://{}/unlock", state.config.bind),
"mcp_url": format!("http://{}/mcp", state.config.bind),
})
}
#[cfg(test)]
mod tests {
use super::*;
use secrets_core::crypto::encrypt;
#[test]
fn verify_key_check_accepts_matching_key() {
let key_hex = "11".repeat(32);
let key = extract_key_from_hex(&key_hex).unwrap();
let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
let ciphertext_hex = hex::encode_hex(&ciphertext);
assert!(verify_key_check_hex(&key_hex, &ciphertext_hex).is_ok());
}
#[test]
fn verify_key_check_rejects_wrong_key() {
let correct_key_hex = "11".repeat(32);
let wrong_key_hex = "22".repeat(32);
let key = extract_key_from_hex(&correct_key_hex).unwrap();
let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
let ciphertext_hex = hex::encode_hex(&ciphertext);
let err = verify_key_check_hex(&wrong_key_hex, &ciphertext_hex).unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
}
}

View File

@@ -1,57 +0,0 @@
本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
## [0.6.0] - 2026-04-12
### Changed
- 重构 `secrets-mcp-local` 为本地 MCP 服务:`initialize` / `tools/list` 在未绑定、未解锁时也始终成功,不再通过连接级 `401` 让 MCP 客户端误判为服务离线。
- 本地 gateway 改为三态工具面:`bootstrap` / `pendingUnlock` / `ready`;未就绪时仅暴露 `local_status``local_bind_start``local_bind_exchange``local_unlock_status``local_onboarding_info` 等 bootstrap 工具。
- 本地首页改为真实 onboarding 页面:可直接发起绑定、展示 `approve_url`、轮询授权结果,并衔接本地 unlock不再要求用户手工拼 `curl` 请求。
- 本地绑定闭环改为持久化短时会话:远程 `secrets-mcp` 新增 `local_mcp_bind_sessions` 存储绑定确认状态,避免仅靠单进程内存状态。
- 本地解锁增加 `key_check` 校验与生命周期收敛:浏览器内先验证密码短语,再缓存本地 unlock当远程 `key_version` 变化、API key 失效或绑定用户缺失时,本地自动失效 unlock 或清除 bound 状态。
- 远程 `secrets-mcp` 新增 `/api/local-mcp/entries/find|search|history|overview|delete-preview|{id}/secrets` JSON APIlocal gateway 的发现、预览删除与解密读取已切到这些 HTTP API不再依赖远程 `/mcp` 作为运行时后端。
- 本地 gateway 新增 `target_exec` 通用代执行能力AI 可先发现服务器或 API 服务条目,再由 local gateway 内部读取条目密钥并注入 `TARGET_*` 环境变量执行标准命令;执行上下文按 `entry_id` 本地缓存,可在 unlock 生命周期内复用。
## [0.5.28] - 2026-04-12
### Added
- 工作区新增 **`secrets-mcp-local`** 并升级为本地 MCP 服务:支持 `bind/start -> approve -> bind/exchange -> /unlock` 闭环,复用远程 Web 会话完成本地绑定,浏览器内派生后按 TTL 缓存解锁状态。
- 远程 `secrets-mcp` 新增本地绑定 API`/api/local-mcp/bind/start``/api/local-mcp/bind/approve``/api/local-mcp/bind/exchange` 以及确认页 `/local-mcp/approve`
## [0.5.27] - 2026-04-11
### Added
- Web **`/entries`**:按 **tags** 筛选逗号分隔、trim、多标签 **AND** 语义,与 `SearchParams` / MCP 一致folder 标签计数、分页与筛选栏状态同步保留 `tags`
## [0.5.26] - 2026-04-11
### Fixed
- **Google OAuth**:工作区 `reqwest` 此前关闭默认特性且未启用 **`system-proxy`**,进程不读取 macOS/Windows 系统代理,易出现与浏览器不一致(本机可上 Google 但换 token 超时)。已显式启用 `system-proxy`
## [0.5.25] - 2026-04-11
### Changed
- Google OAuthtoken / userinfo 请求单独 **45s** 超时(避免仅触达默认客户端 15s失败时区分超时、连接错误并在非 2xx 时记录/返回 Google 响应体片段(如 `invalid_grant``redirect_uri_mismatch`)。
## [0.5.24] - 2026-04-11
### Changed
- 首页页脚将原「登录」入口改为「变更记录」(`/changelog`);顶部导航仍保留登录 / 进入控制台。
## [0.5.23] - 2026-04-11
### Added
- Changelog 页使用 **Markdown** 渲染(`pulldown-cmark`:表格、~~删除线~~、任务列表等)。
## [0.5.22] - 2026-04-11
### Added
- DashboardMCP页脚版本旁增加「变更记录」链接打开本变更说明页。

View File

@@ -1,48 +0,0 @@
[package]
name = "secrets-mcp"
version = "0.6.0"
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", "trace", "limit"] }
tower-sessions = "0.14"
tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["postgres"] }
governor = { version = "0.10", features = ["std", "jitter"] }
time = "0.3"
# 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
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"
url = "2"
pulldown-cmark = "0.13.3"

View File

@@ -1,97 +0,0 @@
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use sqlx::PgPool;
use uuid::Uuid;
use secrets_core::service::api_key::validate_api_key;
use crate::client_ip;
/// Injected into request extensions after Bearer token validation.
#[derive(Clone, Debug)]
pub struct AuthUser {
pub user_id: Uuid,
}
/// 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 = client_ip::extract_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,
"invalid Authorization header format on /mcp (expected Bearer …)"
);
return Err(StatusCode::UNAUTHORIZED);
}
None => {
tracing::warn!(
method,
path,
%client_ip,
"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,
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,
error = %e,
"api key validation error"
);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}

View File

@@ -1,85 +0,0 @@
use axum::extract::Request;
use std::net::{IpAddr, SocketAddr};
/// Extract the client IP from a request.
///
/// When the `TRUST_PROXY` environment variable is set to `1` or `true`, the
/// `X-Forwarded-For` and `X-Real-IP` headers are consulted first, which is
/// appropriate when the service runs behind a trusted reverse proxy (e.g.
/// Caddy). Otherwise — or if those headers are absent/empty — the direct TCP
/// connection address from `ConnectInfo` is used.
///
/// **Important**: only enable `TRUST_PROXY` when the application is guaranteed
/// to receive traffic exclusively through a controlled reverse proxy. Enabling
/// it on a directly-exposed port allows clients to spoof their IP address and
/// bypass per-IP rate limiting.
pub fn extract_client_ip(req: &Request) -> String {
if trust_proxy_enabled() {
if let Some(ip) = forwarded_for_ip(req.headers()) {
return ip;
}
if let Some(ip) = real_ip(req.headers()) {
return ip;
}
}
connect_info_ip(req).unwrap_or_else(|| "unknown".to_string())
}
/// Extract the client IP from individual header map and socket address components.
///
/// This variant is used by handlers that receive headers and connect info as
/// separate axum extractor parameters (e.g. OAuth callback handlers).
/// The same `TRUST_PROXY` logic applies.
pub fn extract_client_ip_parts(
headers: &axum::http::HeaderMap,
addr: std::net::SocketAddr,
) -> String {
if trust_proxy_enabled() {
if let Some(ip) = forwarded_for_ip(headers) {
return ip;
}
if let Some(ip) = real_ip(headers) {
return ip;
}
}
addr.ip().to_string()
}
fn trust_proxy_enabled() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHE.get_or_init(|| {
matches!(
std::env::var("TRUST_PROXY").as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
})
}
fn forwarded_for_ip(headers: &axum::http::HeaderMap) -> Option<String> {
let value = headers.get("x-forwarded-for")?.to_str().ok()?;
let first = value.split(',').next()?.trim();
if first.is_empty() {
None
} else {
validate_ip(first)
}
}
fn real_ip(headers: &axum::http::HeaderMap) -> Option<String> {
let value = headers.get("x-real-ip")?.to_str().ok()?;
let ip = value.trim();
if ip.is_empty() { None } else { validate_ip(ip) }
}
/// Validate that a string is a valid IP address.
/// Returns Some(ip) if valid, None otherwise.
fn validate_ip(s: &str) -> Option<String> {
s.parse::<IpAddr>().ok().map(|ip| ip.to_string())
}
fn connect_info_ip(req: &Request) -> Option<String> {
req.extensions()
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip().to_string())
}

View File

@@ -1,54 +0,0 @@
use secrets_core::error::AppError;
/// Map a structured `AppError` to an MCP protocol error.
///
/// This replaces the previous pattern of swallowing all errors into `-32603`.
pub fn app_error_to_mcp(err: &AppError) -> rmcp::ErrorData {
match err {
AppError::ConflictSecretName { secret_name } => rmcp::ErrorData::invalid_request(
format!(
"A secret with the name '{secret_name}' already exists for your account. \
Secret names must be unique per user."
),
None,
),
AppError::ConflictEntryName { folder, name } => rmcp::ErrorData::invalid_request(
format!(
"An entry with folder='{folder}' and name='{name}' already exists. \
The combination of folder and name must be unique."
),
None,
),
AppError::NotFoundEntry => rmcp::ErrorData::invalid_request(
"Entry not found. Use secrets_find to discover existing entries.",
None,
),
AppError::NotFoundUser => rmcp::ErrorData::invalid_request("User not found.", None),
AppError::NotFoundSecret => rmcp::ErrorData::invalid_request("Secret not found.", None),
AppError::AuthenticationFailed => rmcp::ErrorData::invalid_request(
"Authentication failed. Please check your API key or login credentials.",
None,
),
AppError::Unauthorized => rmcp::ErrorData::invalid_request(
"Unauthorized: you do not have permission to access this resource.",
None,
),
AppError::Validation { message } => rmcp::ErrorData::invalid_request(message.clone(), None),
AppError::ConcurrentModification => rmcp::ErrorData::invalid_request(
"The entry was modified by another request. Please refresh and try again.",
None,
),
AppError::DecryptionFailed => rmcp::ErrorData::invalid_request(
"Decryption failed — the encryption key may be incorrect or does not match the data.",
None,
),
AppError::EncryptionKeyNotSet => rmcp::ErrorData::invalid_request(
"Encryption key not set. You must set a passphrase before using this feature.",
None,
),
AppError::Internal(_) => rmcp::ErrorData::internal_error(
"Request failed due to a server error. Check service logs if you need details.",
None,
),
}
}

View File

@@ -1,381 +0,0 @@
use std::time::Instant;
use axum::{
body::{Body, Bytes, to_bytes},
extract::Request,
http::{
HeaderMap, Method, StatusCode,
header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
},
middleware::Next,
response::{IntoResponse, Response},
};
use crate::auth::AuthUser;
/// Axum middleware that logs structured info for every HTTP request.
///
/// All requests: method, path, status, latency_ms, client_ip, user_agent.
/// POST /mcp requests: additionally parses JSON-RPC body for jsonrpc_method,
/// tool_name, jsonrpc_id, mcp_session, batch_size, tool_args (non-sensitive
/// arguments only), plus masked auth_key / enc_key fingerprints and user_id
/// for diagnosing header forwarding issues.
///
/// Sensitive headers (Authorization, X-Encryption-Key) are never logged in
/// full — only short fingerprints are emitted.
pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let ip = client_ip(&req);
let ua = header_str(req.headers(), USER_AGENT);
let content_len = header_str(req.headers(), CONTENT_LENGTH).and_then(|v| v.parse::<u64>().ok());
let mcp_session = req
.headers()
.get("mcp-session-id")
.or_else(|| req.headers().get("x-mcp-session"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Capture header fingerprints before consuming the request.
let auth_key = mask_bearer(req.headers());
let enc_key = mask_enc_key(req.headers());
let is_mcp_post = path.starts_with("/mcp") && method == Method::POST;
let is_json = header_str(req.headers(), CONTENT_TYPE)
.map(|ct| ct.contains("application/json"))
.unwrap_or(false);
let start = Instant::now();
// For MCP JSON-RPC POST requests, buffer body to extract JSON-RPC metadata.
// We cap at 512 KiB to avoid buffering large payloads.
if is_mcp_post && is_json {
let cap = content_len.unwrap_or(0);
if cap <= 512 * 1024 {
let (parts, body) = req.into_parts();
// user_id is available after auth middleware has run (injected into extensions).
let user_id = parts
.extensions
.get::<AuthUser>()
.map(|a| a.user_id.to_string());
match to_bytes(body, 512 * 1024).await {
Ok(bytes) => {
let rpc = parse_jsonrpc_meta(&bytes);
let req = Request::from_parts(parts, Body::from(bytes));
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed = start.elapsed().as_millis();
log_mcp_request(
&method,
&path,
status,
elapsed,
ip.as_deref(),
ua.as_deref(),
content_len,
mcp_session.as_deref(),
auth_key.as_deref(),
&enc_key,
user_id.as_deref(),
&rpc,
);
return resp;
}
Err(e) => {
tracing::warn!(path, error = %e, "failed to buffer MCP request body for logging");
let elapsed = start.elapsed().as_millis();
tracing::info!(
method = method.as_str(),
path,
status = StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
elapsed_ms = elapsed,
client_ip = ip.as_deref(),
ua = ua.as_deref(),
content_length = content_len,
mcp_session = mcp_session.as_deref(),
auth_key = auth_key.as_deref(),
enc_key = enc_key.as_str(),
user_id = user_id.as_deref(),
"mcp request",
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"failed to read request body",
)
.into_response();
}
}
}
}
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed = start.elapsed().as_millis();
// Known client probe patterns that legitimately 404 — downgrade to debug to
// avoid noise in production logs. These are:
// • GET /.well-known/* — OAuth/OIDC discovery by MCP clients (RFC 8414 / RFC 9728)
// • GET /mcp → 404 — old SSE-transport compatibility probe by clients
let is_expected_probe_404 = status == 404
&& (path.starts_with("/.well-known/")
|| (method == Method::GET && path.starts_with("/mcp")));
if is_expected_probe_404 {
tracing::debug!(
method = method.as_str(),
path,
status,
elapsed_ms = elapsed,
client_ip = ip.as_deref(),
ua = ua.as_deref(),
"probe request (not found — expected)",
);
} else {
log_http_request(
&method,
&path,
status,
elapsed,
ip.as_deref(),
ua.as_deref(),
content_len,
);
}
resp
}
// ── Logging helpers ───────────────────────────────────────────────────────────
fn log_http_request(
method: &Method,
path: &str,
status: u16,
elapsed_ms: u128,
client_ip: Option<&str>,
ua: Option<&str>,
content_length: Option<u64>,
) {
tracing::info!(
method = method.as_str(),
path,
status,
elapsed_ms,
client_ip,
ua,
content_length,
"http request",
);
}
#[allow(clippy::too_many_arguments)]
fn log_mcp_request(
method: &Method,
path: &str,
status: u16,
elapsed_ms: u128,
client_ip: Option<&str>,
ua: Option<&str>,
content_length: Option<u64>,
mcp_session: Option<&str>,
auth_key: Option<&str>,
enc_key: &str,
user_id: Option<&str>,
rpc: &JsonRpcMeta,
) {
tracing::info!(
method = method.as_str(),
path,
status,
elapsed_ms,
client_ip,
ua,
content_length,
mcp_session,
jsonrpc = rpc.rpc_method.as_deref(),
tool = rpc.tool_name.as_deref(),
jsonrpc_id = rpc.request_id.as_deref(),
batch_size = rpc.batch_size,
tool_args = rpc.tool_args.as_deref(),
auth_key,
enc_key,
user_id,
"mcp request",
);
}
// ── Sensitive header masking ──────────────────────────────────────────────────
/// Mask a Bearer token: emit only the first 12 characters followed by `…`.
/// Returns `None` if the Authorization header is absent or not a Bearer token.
/// Example: `sk_90c88844e4e5…`
fn mask_bearer(headers: &HeaderMap) -> Option<String> {
let val = headers.get(AUTHORIZATION)?.to_str().ok()?;
let token = val.strip_prefix("Bearer ")?.trim();
if token.is_empty() {
return None;
}
if token.len() > 12 {
Some(format!("{}", &token[..12]))
} else {
Some(token.to_string())
}
}
/// Fingerprint the X-Encryption-Key header.
///
/// Emits first 4 chars, last 4 chars, and raw byte length, e.g. `146b…5516(64)`.
/// Returns `"absent"` when the header is missing. Reveals enough to confirm
/// which key arrived and whether it was truncated or padded, without revealing
/// the full value.
fn mask_enc_key(headers: &HeaderMap) -> String {
match headers
.get("x-encryption-key")
.and_then(|v| v.to_str().ok())
{
Some(val) => {
let raw_len = val.len();
let t = val.trim();
let len = t.len();
if len >= 8 {
let prefix = &t[..4];
let suffix = &t[len - 4..];
if raw_len != len {
// Trailing/leading whitespace detected — extra diagnostic.
format!("{prefix}{suffix}({len}, raw={raw_len})")
} else {
format!("{prefix}{suffix}({len})")
}
} else {
format!("…({len})")
}
}
None => "absent".to_string(),
}
}
// ── JSON-RPC body parsing ─────────────────────────────────────────────────────
/// Safe (non-sensitive) argument keys that may be included verbatim in logs.
/// Keys NOT in this list (e.g. `secrets`, `secrets_obj`, `meta_obj`,
/// `encryption_key`) are silently dropped.
const SAFE_ARG_KEYS: &[&str] = &[
"id",
"name",
"name_query",
"folder",
"type",
"entry_type",
"field",
"query",
"tags",
"limit",
"offset",
"format",
"dry_run",
"prefix",
];
#[derive(Debug, Default)]
struct JsonRpcMeta {
request_id: Option<String>,
rpc_method: Option<String>,
tool_name: Option<String>,
batch_size: Option<usize>,
/// Non-sensitive tool call arguments for diagnostic logging.
tool_args: Option<String>,
}
fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return JsonRpcMeta::default();
};
if let Some(arr) = value.as_array() {
// Batch request: summarise method(s) from first element only
let first = arr.first().map(parse_single).unwrap_or_default();
return JsonRpcMeta {
batch_size: Some(arr.len()),
..first
};
}
parse_single(&value)
}
fn parse_single(value: &serde_json::Value) -> JsonRpcMeta {
let request_id = value.get("id").and_then(json_to_string);
let rpc_method = value
.get("method")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tool_name = value
.pointer("/params/name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tool_args = extract_tool_args(value);
JsonRpcMeta {
request_id,
rpc_method,
tool_name,
batch_size: None,
tool_args,
}
}
/// Extract a compact summary of non-sensitive tool arguments for logging.
/// Only keys listed in `SAFE_ARG_KEYS` are included.
fn extract_tool_args(value: &serde_json::Value) -> Option<String> {
let args = value.pointer("/params/arguments")?;
let obj = args.as_object()?;
let pairs: Vec<String> = obj
.iter()
.filter(|(k, v)| SAFE_ARG_KEYS.contains(&k.as_str()) && !v.is_null())
.map(|(k, v)| format!("{}={}", k, summarize_value(v)))
.collect();
if pairs.is_empty() {
None
} else {
Some(pairs.join(" "))
}
}
/// Produce a short, log-safe representation of a JSON value.
fn summarize_value(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => {
if s.len() > 64 {
format!("\"{}\"", &s[..64])
} else {
format!("\"{s}\"")
}
}
serde_json::Value::Array(arr) => format!("[…{}]", arr.len()),
serde_json::Value::Object(_) => "{…}".to_string(),
other => other.to_string(),
}
}
fn json_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
other => Some(other.to_string()),
}
}
// ── Header helpers ────────────────────────────────────────────────────────────
fn header_str(headers: &HeaderMap, name: impl axum::http::header::AsHeaderName) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
fn client_ip(req: &Request) -> Option<String> {
crate::client_ip::extract_client_ip(req).into()
}

View File

@@ -1,366 +0,0 @@
mod auth;
mod client_ip;
mod error;
mod logging;
mod oauth;
mod rate_limit;
mod tools;
mod validation;
mod web;
use std::net::SocketAddr;
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::session_store::ExpiredDeletion;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store_chrono::PostgresStore;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::time::FormatTime;
use secrets_core::config::resolve_db_config;
use secrets_core::db::{create_pool, migrate};
use secrets_core::service::delete::purge_expired_deleted_entries;
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())
}
/// Pretty-print bind address in logs (`127.0.0.1` → `localhost`); actual socket bind unchanged.
fn listen_addr_log_display(bind_addr: &str) -> String {
bind_addr
.strip_prefix("127.0.0.1:")
.map(|port| format!("localhost:{port}"))
.unwrap_or_else(|| bind_addr.to_string())
}
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),
})
}
/// Log line timestamps in the process local timezone (honors `TZ` / system zone).
#[derive(Clone, Copy, Default)]
struct LocalRfc3339Time;
impl FormatTime for LocalRfc3339Time {
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
write!(
w,
"{}",
chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, false)
)
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Load .env if present
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_timer(LocalRfc3339Time)
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "secrets_mcp=info,tower_http=info".into()),
)
.init();
// ── Database ──────────────────────────────────────────────────────────────
let db_config = resolve_db_config("")
.context("Database not configured. Set SECRETS_DATABASE_URL environment variable.")?;
let pool = create_pool(&db_config)
.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(|| "127.0.0.1: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 (PostgreSQL-backed) ─────────────────────────────────────
let session_store = PostgresStore::new(pool.clone());
session_store
.migrate()
.await
.context("failed to run session table migration")?;
// Prune expired rows every hour; task is aborted when the server shuts down.
let session_cleanup = tokio::spawn(
session_store
.clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(3600)),
);
// 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)
.with_expiry(Expiry::OnInactivity(time::Duration::days(14)));
// ── 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_for_mcp = pool.clone();
let mcp_service = StreamableHttpService::new(
move || {
let p = pool_for_mcp.clone();
Ok(SecretsService::new(p))
},
LocalSessionManager::default().into(),
Default::default(),
);
// ── Router ────────────────────────────────────────────────────────────────
// CORS: restrict origins in production, allow all in development
let is_production = matches!(
load_env_var("SECRETS_ENV")
.as_deref()
.map(|s| s.to_ascii_lowercase())
.as_deref(),
Some("prod" | "production")
);
let cors = build_cors_layer(&base_url, is_production);
// Rate limiting
let rate_limit_state = rate_limit::RateLimitState::new();
let rate_limit_cleanup = rate_limit::spawn_cleanup_task(rate_limit_state.ip_limiter.clone());
let recycle_bin_cleanup = tokio::spawn(start_recycle_bin_cleanup_task(pool.clone()));
let router = Router::new()
.merge(web::web_router())
.nest_service("/mcp", mcp_service)
.layer(axum::middleware::from_fn(
logging::request_logging_middleware,
))
.layer(axum::middleware::from_fn_with_state(
pool,
auth::bearer_auth_middleware,
))
.layer(axum::middleware::from_fn_with_state(
rate_limit_state.clone(),
rate_limit::rate_limit_middleware,
))
.layer(session_layer)
.layer(cors)
.layer(tower_http::limit::RequestBodyLimitLayer::new(
10 * 1024 * 1024,
))
.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://{}",
listen_addr_log_display(&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")?;
session_cleanup.abort();
rate_limit_cleanup.abort();
recycle_bin_cleanup.abort();
Ok(())
}
async fn start_recycle_bin_cleanup_task(pool: PgPool) {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(24 * 60 * 60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
match purge_expired_deleted_entries(&pool).await {
Ok(count) if count > 0 => {
tracing::info!(purged_count = count, "purged expired recycle bin entries");
}
Ok(_) => {}
Err(error) => {
tracing::warn!(error = %error, "failed to purge expired recycle bin entries");
}
}
}
}
async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Shutting down gracefully...");
}
/// Production CORS allowed headers.
///
/// When adding a new custom header to the MCP or Web API, this list must be
/// updated accordingly — otherwise browsers will block the request during
/// the CORS preflight check.
fn production_allowed_headers() -> [axum::http::HeaderName; 5] {
[
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
axum::http::HeaderName::from_static("x-encryption-key"),
axum::http::HeaderName::from_static("mcp-session-id"),
axum::http::HeaderName::from_static("x-mcp-session"),
]
}
/// Production CORS allowed methods.
///
/// Keep this list explicit because tower-http rejects
/// `allow_credentials(true)` together with `allow_methods(Any)`.
fn production_allowed_methods() -> [axum::http::Method; 5] {
[
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PATCH,
axum::http::Method::DELETE,
axum::http::Method::OPTIONS,
]
}
/// Build the CORS layer for the application.
///
/// In production mode the origin is restricted to the BASE_URL origin
/// (scheme://host:port, path stripped) and credentials are allowed.
/// `allow_headers` and `allow_methods` use explicit whitelists to avoid the
/// tower-http restriction on `allow_credentials(true)` + wildcards.
///
/// In development mode all origins, methods and headers are allowed.
fn build_cors_layer(base_url: &str, is_production: bool) -> CorsLayer {
if is_production {
let allowed_origin = if let Ok(parsed) = base_url.parse::<url::Url>() {
let origin = parsed.origin().ascii_serialization();
origin
.parse::<axum::http::HeaderValue>()
.unwrap_or_else(|_| panic!("invalid BASE_URL origin: {}", origin))
} else {
base_url
.parse::<axum::http::HeaderValue>()
.unwrap_or_else(|_| panic!("invalid BASE_URL: {}", base_url))
};
CorsLayer::new()
.allow_origin(allowed_origin)
.allow_methods(production_allowed_methods())
.allow_headers(production_allowed_headers())
.allow_credentials(true)
} else {
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn production_cors_does_not_panic() {
let layer = build_cors_layer("https://secrets.example.com/app", true);
let _ = layer;
}
#[test]
fn production_cors_headers_include_all_required() {
let headers = production_allowed_headers();
let names: Vec<&str> = headers.iter().map(|h| h.as_str()).collect();
assert!(names.contains(&"authorization"));
assert!(names.contains(&"content-type"));
assert!(names.contains(&"x-encryption-key"));
assert!(names.contains(&"mcp-session-id"));
assert!(names.contains(&"x-mcp-session"));
}
#[test]
fn production_cors_methods_include_all_required() {
let methods = production_allowed_methods();
assert!(methods.contains(&axum::http::Method::GET));
assert!(methods.contains(&axum::http::Method::POST));
assert!(methods.contains(&axum::http::Method::PATCH));
assert!(methods.contains(&axum::http::Method::DELETE));
assert!(methods.contains(&axum::http::Method::OPTIONS));
}
#[test]
fn production_cors_normalizes_base_url_with_path() {
let url = url::Url::parse("https://secrets.example.com/secrets/app").unwrap();
let origin = url.origin().ascii_serialization();
assert_eq!(origin, "https://secrets.example.com");
}
#[test]
fn development_cors_allows_everything() {
let layer = build_cors_layer("http://localhost:9315", false);
let _ = layer;
}
}

View File

@@ -1,116 +0,0 @@
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
use super::{OAuthConfig, OAuthUserInfo};
/// OAuth token / userinfo calls can be slow on poor routes; keep above client default if needed.
const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(45);
#[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>,
}
fn map_reqwest_send_err(e: reqwest::Error) -> anyhow::Error {
if e.is_timeout() {
anyhow::anyhow!(
"timeout reaching Google OAuth ({}s); ensure outbound HTTPS to oauth2.googleapis.com works (firewall/proxy/VPN if Google is unreachable)",
OAUTH_HTTP_TIMEOUT.as_secs()
)
} else if e.is_connect() {
anyhow::anyhow!("connection error to Google OAuth: {e}")
} else {
anyhow::Error::new(e)
}
}
/// 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_http = client
.post("https://oauth2.googleapis.com/token")
.timeout(OAUTH_HTTP_TIMEOUT)
.form(&[
("code", code),
("client_id", &config.client_id),
("client_secret", &config.client_secret),
("redirect_uri", &config.redirect_uri),
("grant_type", "authorization_code"),
])
.send()
.await
.map_err(map_reqwest_send_err)
.context("Google token HTTP request failed")?;
let status = token_http.status();
let body_bytes = token_http
.bytes()
.await
.context("read Google token response body")?;
if !status.is_success() {
let body_lossy = String::from_utf8_lossy(&body_bytes);
tracing::warn!(%status, body = %body_lossy, "Google token endpoint error");
anyhow::bail!(
"Google token error {}: {}",
status,
body_lossy.chars().take(512).collect::<String>()
);
}
let token_resp: TokenResponse =
serde_json::from_slice(&body_bytes).context("failed to parse Google token JSON")?;
let user_http = client
.get("https://openidconnect.googleapis.com/v1/userinfo")
.timeout(OAUTH_HTTP_TIMEOUT)
.bearer_auth(&token_resp.access_token)
.send()
.await
.map_err(map_reqwest_send_err)
.context("Google userinfo HTTP request failed")?;
let status = user_http.status();
let body_bytes = user_http
.bytes()
.await
.context("read Google userinfo body")?;
if !status.is_success() {
let body_lossy = String::from_utf8_lossy(&body_bytes);
tracing::warn!(%status, body = %body_lossy, "Google userinfo endpoint error");
anyhow::bail!(
"Google userinfo error {}: {}",
status,
body_lossy.chars().take(512).collect::<String>()
);
}
let user: UserInfo =
serde_json::from_slice(&body_bytes).context("failed to parse Google userinfo JSON")?;
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);
secrets_core::crypto::hex::encode_hex(&bytes)
}

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)] // Placeholder — implement when WeChat login is needed.
pub async fn exchange_code(
_client: &reqwest::Client,
_config: &OAuthConfig,
_code: &str,
) -> Result<OAuthUserInfo> {
bail!("WeChat login is not yet implemented")
}

View File

@@ -1,160 +0,0 @@
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::{Request, State},
http::{HeaderMap, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use governor::{
Quota, RateLimiter,
clock::{Clock, DefaultClock},
state::{InMemoryState, NotKeyed, keyed::DashMapStateStore},
};
use serde_json::json;
use crate::client_ip;
/// Per-IP rate limiter (keyed by client IP string)
type IpRateLimiter = RateLimiter<String, DashMapStateStore<String>, DefaultClock>;
/// Global rate limiter (not keyed)
type GlobalRateLimiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
/// Parse a u32 env value into NonZeroU32, logging a warning and falling back
/// to the default if the value is zero.
fn nz_or_log(value: u32, default: u32, name: &str) -> NonZeroU32 {
NonZeroU32::new(value).unwrap_or_else(|| {
tracing::warn!(
configured = value,
default,
"{name} must be non-zero, using default"
);
NonZeroU32::new(default).unwrap()
})
}
#[derive(Clone)]
pub struct RateLimitState {
pub ip_limiter: Arc<IpRateLimiter>,
pub global_limiter: Arc<GlobalRateLimiter>,
}
impl RateLimitState {
/// Create a new RateLimitState with default limits.
///
/// Default limits (can be overridden via environment variables):
/// - Global: 100 req/s, burst 200
/// - Per-IP: 20 req/s, burst 40
pub fn new() -> Self {
let global_rate = std::env::var("RATE_LIMIT_GLOBAL_PER_SECOND")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(100);
let global_burst = std::env::var("RATE_LIMIT_GLOBAL_BURST")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(200);
let ip_rate = std::env::var("RATE_LIMIT_IP_PER_SECOND")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(20);
let ip_burst = std::env::var("RATE_LIMIT_IP_BURST")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(40);
let global_rate_nz = nz_or_log(global_rate, 100, "RATE_LIMIT_GLOBAL_PER_SECOND");
let global_burst_nz = nz_or_log(global_burst, 200, "RATE_LIMIT_GLOBAL_BURST");
let ip_rate_nz = nz_or_log(ip_rate, 20, "RATE_LIMIT_IP_PER_SECOND");
let ip_burst_nz = nz_or_log(ip_burst, 40, "RATE_LIMIT_IP_BURST");
let global_quota = Quota::per_second(global_rate_nz).allow_burst(global_burst_nz);
let ip_quota = Quota::per_second(ip_rate_nz).allow_burst(ip_burst_nz);
tracing::info!(
global_rate = global_rate_nz.get(),
global_burst = global_burst_nz.get(),
ip_rate = ip_rate_nz.get(),
ip_burst = ip_burst_nz.get(),
"rate limiter initialized"
);
Self {
global_limiter: Arc::new(RateLimiter::direct(global_quota)),
ip_limiter: Arc::new(RateLimiter::dashmap(ip_quota)),
}
}
}
/// Rate limiting middleware function.
///
/// Checks both global and per-IP rate limits before allowing the request through.
/// Returns 429 Too Many Requests if either limit is exceeded.
pub async fn rate_limit_middleware(
State(rl): State<RateLimitState>,
req: Request,
next: Next,
) -> Result<Response, Response> {
// Check global rate limit first
if let Err(negative) = rl.global_limiter.check() {
let retry_after = negative.wait_time_from(DefaultClock::default().now());
tracing::warn!(
retry_after_secs = retry_after.as_secs(),
"global rate limit exceeded"
);
return Err(too_many_requests_response(Some(retry_after)));
}
// Check per-IP rate limit
let key = client_ip::extract_client_ip(&req);
if let Err(negative) = rl.ip_limiter.check_key(&key) {
let retry_after = negative.wait_time_from(DefaultClock::default().now());
tracing::warn!(
client_ip = %key,
retry_after_secs = retry_after.as_secs(),
"per-IP rate limit exceeded"
);
return Err(too_many_requests_response(Some(retry_after)));
}
Ok(next.run(req).await)
}
/// Start a background task to clean up expired rate limiter entries.
///
/// This should be called once during application startup.
/// The task runs every 60 seconds and will be aborted on shutdown.
pub fn spawn_cleanup_task(ip_limiter: Arc<IpRateLimiter>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
ip_limiter.retain_recent();
}
})
}
/// Create a 429 Too Many Requests response.
fn too_many_requests_response(retry_after: Option<Duration>) -> Response {
let mut headers = HeaderMap::new();
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
if let Some(duration) = retry_after {
let secs = duration.as_secs().max(1);
if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
headers.insert("Retry-After", value);
}
}
let body = json!({
"error": "Too many requests, please try again later"
});
(StatusCode::TOO_MANY_REQUESTS, headers, body.to_string()).into_response()
}

Some files were not shown because too many files have changed in this diff Show More