feat: 客户端加密 encrypted 字段,数据库只存密文 (v0.5.0)
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m27s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m14s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 11m1s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

- 新增 src/crypto.rs:AES-256-GCM 加解密 + Argon2id 密钥派生 + OS Keychain 读写
- 新增 `secrets init` 命令:输入 Master Password,派生 Master Key 存入 Keychain
- 新增 `secrets migrate-encrypt` 命令:将旧明文 JSONB 数据批量加密
- 修改 db.rs:encrypted 列 JSONB → BYTEA,新增 kv_config 表(存 Argon2id salt)
- 修改 models.rs:encrypted 字段类型 Value → Vec<u8>
- 修改 add/update:写入前 encrypt_json,update 读取后 decrypt → 合并 → 重新加密
- 修改 search:按需解密,未解密时显示 _encrypted:true/_key_count:N
- 通过 6 个 crypto 单元测试(加解密、JSON roundtrip、Argon2id 确定性)

Made-with: Cursor
This commit is contained in:
voson
2026-03-18 20:10:13 +08:00
parent 1f7984d798
commit 8fdb6db87b
12 changed files with 828 additions and 66 deletions

View File

@@ -23,7 +23,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
name VARCHAR(256) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
encrypted JSONB NOT NULL DEFAULT '{}',
encrypted BYTEA NOT NULL DEFAULT '\x',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
@@ -35,11 +35,37 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
-- Migrate encrypted column from JSONB to BYTEA if still JSONB type.
-- After migration, old plaintext rows will have their JSONB data
-- stored as raw bytes (not yet re-encrypted); run `secrets migrate-encrypt`
-- to encrypt them with the master key.
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'secrets'
AND column_name = 'encrypted'
AND data_type = 'jsonb'
) THEN
ALTER TABLE secrets RENAME COLUMN encrypted TO encrypted_jsonb_old;
ALTER TABLE secrets ADD COLUMN encrypted BYTEA NOT NULL DEFAULT '\x';
-- Copy existing JSONB data as raw UTF-8 bytes so nothing is lost
UPDATE secrets SET encrypted = convert_to(encrypted_jsonb_old::text, 'UTF8');
ALTER TABLE secrets DROP COLUMN encrypted_jsonb_old;
END IF;
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_secrets_namespace ON secrets(namespace);
CREATE INDEX IF NOT EXISTS idx_secrets_kind ON secrets(kind);
CREATE INDEX IF NOT EXISTS idx_secrets_tags ON secrets USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_secrets_metadata ON secrets USING GIN(metadata jsonb_path_ops);
-- Key-value config table: stores Argon2id salt (shared across devices)
CREATE TABLE IF NOT EXISTS kv_config (
key TEXT PRIMARY KEY,
value BYTEA NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL,
@@ -60,3 +86,25 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
tracing::debug!("migrations complete");
Ok(())
}
/// Load the Argon2id salt from the database.
/// Returns None if not yet initialized.
pub async fn load_argon2_salt(pool: &PgPool) -> Result<Option<Vec<u8>>> {
let row: Option<(Vec<u8>,)> =
sqlx::query_as("SELECT value FROM kv_config WHERE key = 'argon2_salt'")
.fetch_optional(pool)
.await?;
Ok(row.map(|(v,)| v))
}
/// Store the Argon2id salt in the database (only called once on first device init).
pub async fn store_argon2_salt(pool: &PgPool, salt: &[u8]) -> Result<()> {
sqlx::query(
"INSERT INTO kv_config (key, value) VALUES ('argon2_salt', $1) \
ON CONFLICT (key) DO NOTHING",
)
.bind(salt)
.execute(pool)
.await?;
Ok(())
}