diff --git a/.gitea/workflows/secrets.yml b/.gitea/workflows/secrets.yml index b39822d..4c09584 100644 --- a/.gitea/workflows/secrets.yml +++ b/.gitea/workflows/secrets.yml @@ -5,6 +5,8 @@ on: branches: [main] paths: - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -331,11 +333,14 @@ jobs: - name: 安装依赖 shell: pwsh run: | + $cargoBin = Join-Path $env:USERPROFILE ".cargo\bin" if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe .\rustup-init.exe -y --default-toolchain stable Remove-Item rustup-init.exe } + $env:Path = "$cargoBin;$env:Path" + Add-Content -Path $env:GITHUB_PATH -Value $cargoBin rustup target add x86_64-pc-windows-msvc - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index f9829b4..59493d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.7.0" +version = "0.7.1" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index c7ab2d1..bab0834 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets" -version = "0.7.0" +version = "0.7.1" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 9fccd95..9f2f030 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ cargo build --release # 1. 配置数据库连接(会先验证连接可用再写入) secrets config set-db "postgres://postgres:@:/secrets" -# 2. 初始化主密钥(提示输入主密码,派生后存入 OS 钥匙串) +# 2. 初始化主密钥(提示输入至少 8 位的主密码,派生后存入 OS 钥匙串) secrets init ``` -主密码不会存储,仅用于派生主密钥。同一主密码在所有设备上会得到相同主密钥(salt 存于数据库,首台设备生成后共享)。 +主密码不会存储,仅用于派生主密钥,且至少需 8 位。同一主密码在所有设备上会得到相同主密钥(salt 存于数据库,首台设备生成后共享)。 **主密钥存储**:macOS → Keychain;Windows → Credential Manager;Linux → keyutils(会话级,重启后需再次 `secrets init`)。 @@ -131,7 +131,7 @@ secrets update -n refining --kind service --name mqtt --remove-meta old_port --r secrets delete -n refining --kind service --name legacy-mqtt # ── init ───────────────────────────────────────────────────────────────────── -secrets init # 主密钥初始化(每台设备一次,主密码派生后存钥匙串) +secrets init # 主密钥初始化(每台设备一次,主密码至少 8 位,派生后存钥匙串) # ── config ─────────────────────────────────────────────────────────────────── secrets config set-db "postgres://postgres:@:/secrets" # 先验证再写入 diff --git a/src/commands/init.rs b/src/commands/init.rs index 3deceaf..de42986 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -4,15 +4,23 @@ use sqlx::PgPool; use crate::{crypto, db}; +const MIN_MASTER_PASSWORD_LEN: usize = 8; + pub async fn run(pool: &PgPool) -> Result<()> { println!("Initializing secrets master key..."); println!(); // Read password (no echo) - let password = - rpassword::prompt_password("Enter master password: ").context("failed to read password")?; - if password.is_empty() { - anyhow::bail!("Master password must not be empty."); + let password = rpassword::prompt_password(format!( + "Enter master password (at least {} characters): ", + MIN_MASTER_PASSWORD_LEN + )) + .context("failed to read password")?; + if password.chars().count() < MIN_MASTER_PASSWORD_LEN { + anyhow::bail!( + "Master password must be at least {} characters.", + MIN_MASTER_PASSWORD_LEN + ); } let confirm = rpassword::prompt_password("Confirm master password: ") .context("failed to read password confirmation")?; diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index 51e7a79..5a50d53 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -3,6 +3,7 @@ use flate2::read::GzDecoder; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::io::{Cursor, Read, Write}; +use std::time::Duration; const GITEA_API: &str = "https://gitea.refining.dev/api/v1/repos/refining/secrets/releases/latest"; @@ -28,16 +29,17 @@ fn available_assets(assets: &[Asset]) -> String { .join(", ") } -fn find_asset_by_suffix<'a>(assets: &'a [Asset], suffix: &str) -> Result<&'a Asset> { - assets - .iter() - .find(|a| a.name.ends_with(suffix)) - .with_context(|| { - format!( - "no asset found for this platform (looking for suffix: {suffix})\navailable: {}", - available_assets(assets) - ) - }) +fn release_asset_name(tag_name: &str, suffix: &str) -> String { + format!("secrets-{tag_name}-{suffix}") +} + +fn find_asset_by_name<'a>(assets: &'a [Asset], name: &str) -> Result<&'a Asset> { + assets.iter().find(|a| a.name == name).with_context(|| { + format!( + "no matching release asset found: {name}\navailable: {}", + available_assets(assets) + ) + }) } /// Detect the asset suffix for the current platform/arch at compile time. @@ -89,6 +91,22 @@ fn sha256_hex(bytes: &[u8]) -> String { format!("{digest:x}") } +fn verify_checksum(asset_name: &str, archive: &[u8], checksum_contents: &str) -> Result { + let expected_checksum = parse_checksum_file(checksum_contents)?; + let actual_checksum = sha256_hex(archive); + + if actual_checksum != expected_checksum { + bail!( + "checksum verification failed for {}: expected {}, got {}", + asset_name, + expected_checksum, + actual_checksum + ); + } + + Ok(actual_checksum) +} + fn parse_checksum_file(contents: &str) -> Result { let checksum = contents .split_whitespace() @@ -163,6 +181,8 @@ pub async fn run(check_only: bool) -> Result<()> { let client = reqwest::Client::builder() .user_agent(format!("secrets-cli/{CURRENT_VERSION}")) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(120)) .build() .context("failed to build HTTP client")?; @@ -192,18 +212,10 @@ pub async fn run(check_only: bool) -> Result<()> { } let suffix = platform_asset_suffix()?; - let asset = find_asset_by_suffix(&release.assets, suffix)?; + let asset_name = release_asset_name(&release.tag_name, suffix); + let asset = find_asset_by_name(&release.assets, &asset_name)?; let checksum_name = format!("{}.sha256", asset.name); - let checksum_asset = release - .assets - .iter() - .find(|a| a.name == checksum_name) - .with_context(|| { - format!( - "missing checksum asset for download: {checksum_name}\navailable: {}", - available_assets(&release.assets) - ) - })?; + let checksum_asset = find_asset_by_name(&release.assets, &checksum_name)?; println!("Downloading {}...", asset.name); @@ -214,19 +226,11 @@ pub async fn run(check_only: bool) -> Result<()> { "checksum download", ) .await?; - let expected_checksum = parse_checksum_file( + let actual_checksum = verify_checksum( + &asset.name, + &archive, std::str::from_utf8(&checksum_contents).context("checksum file is not valid UTF-8")?, )?; - let actual_checksum = sha256_hex(&archive); - - if actual_checksum != expected_checksum { - bail!( - "checksum verification failed for {}: expected {}, got {}", - asset.name, - expected_checksum, - actual_checksum - ); - } println!("Verified SHA-256: {actual_checksum}"); @@ -298,6 +302,33 @@ mod tests { assert!(err.to_string().contains("invalid SHA-256 checksum format")); } + #[test] + fn release_asset_name_matches_release_tag() { + assert_eq!( + release_asset_name("secrets-0.7.0", "x86_64-linux-musl.tar.gz"), + "secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz" + ); + } + + #[test] + fn find_asset_by_name_rejects_stale_platform_match() { + let assets = vec![ + Asset { + name: "secrets-secrets-0.6.9-x86_64-linux-musl.tar.gz".into(), + browser_download_url: "https://example.invalid/old".into(), + }, + Asset { + name: "secrets-secrets-0.7.0-aarch64-macos.tar.gz".into(), + browser_download_url: "https://example.invalid/other".into(), + }, + ]; + + let err = find_asset_by_name(&assets, "secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz") + .expect_err("stale asset should not match"); + + assert!(err.to_string().contains("no matching release asset found")); + } + #[test] fn sha256_hex_matches_known_value() { assert_eq!( @@ -306,6 +337,18 @@ mod tests { ); } + #[test] + fn verify_checksum_rejects_mismatch() { + let err = verify_checksum( + "secrets.tar.gz", + b"abc", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef secrets.tar.gz", + ) + .expect_err("checksum mismatch should fail"); + + assert!(err.to_string().contains("checksum verification failed")); + } + #[test] fn extract_from_targz_reads_binary() { let payload = b"fake-secrets-binary";