Compare commits
12 commits
c301b25450
...
e5be6f5a56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5be6f5a56 | ||
|
|
2f610bf10a | ||
|
|
34626711eb | ||
|
|
cea16c24ae | ||
| 59cefe8435 | |||
|
|
2e9df1c0b9 | ||
|
|
69e136cab0 | ||
|
|
99fef19a6b | ||
| 8afcafe890 | |||
|
|
60bf43fd65 | ||
| b5c81b2a01 | |||
|
|
8e5228e61c |
17 changed files with 938 additions and 39 deletions
97
.forgejo/workflows/check.yml
Normal file
97
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
name: PR Check
|
||||||
|
|
||||||
|
# Validates Rust + frontend on every branch push and PR.
|
||||||
|
# Goal: catch compile errors, type errors, and failing tests BEFORE merge,
|
||||||
|
# instead of waiting for the release tag (which is when release.yml runs).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
runs-on: ubuntu
|
||||||
|
container: ubuntu:22.04
|
||||||
|
env:
|
||||||
|
PATH: /root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies, Node.js and Rust
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl wget git ca-certificates build-essential pkg-config \
|
||||||
|
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev
|
||||||
|
# Node.js is required by actions/checkout and actions/cache (they
|
||||||
|
# are JavaScript actions and need `node` in the container PATH).
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
# Rust toolchain
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
node --version
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache cargo registry and git
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache cargo build target
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-target-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-target-
|
||||||
|
|
||||||
|
- name: cargo check
|
||||||
|
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu
|
||||||
|
container: ubuntu:22.04
|
||||||
|
steps:
|
||||||
|
- name: Install Node.js 20
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends curl ca-certificates git
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache npm cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-npm-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build (tsc + vite)
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Tests (vitest)
|
||||||
|
run: npm test
|
||||||
68
.github/workflows/check.yml
vendored
Normal file
68
.github/workflows/check.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
name: PR Check
|
||||||
|
|
||||||
|
# Mirror of .forgejo/workflows/check.yml using GitHub-native runners.
|
||||||
|
# Forgejo is the primary host; this file keeps the GitHub mirror functional.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev
|
||||||
|
|
||||||
|
- name: Install Rust toolchain (stable)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: cargo check
|
||||||
|
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build (tsc + vite)
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Tests (vitest)
|
||||||
|
run: npm test
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Hachage du PIN migré de SHA-256 vers Argon2id pour résistance au brute-force (CWE-916). Les PINs SHA-256 existants sont vérifiés de façon transparente ; les nouveaux PINs utilisent Argon2id (#54)
|
||||||
|
|
||||||
## [0.6.7] - 2026-03-29
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- PIN hashing migrated from SHA-256 to Argon2id for brute-force resistance (CWE-916). Existing SHA-256 PINs are verified transparently; new PINs use Argon2id (#54)
|
||||||
|
|
||||||
## [0.6.7] - 2026-03-29
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -157,9 +157,8 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
- GitHub Actions (`release.yml`) déclenché par tags `v*`
|
- **`check.yml`** (Forgejo Actions + miroir GitHub) — déclenché sur chaque push de branche (sauf `main`) et chaque PR vers `main`. Lance `cargo check`, `cargo test`, `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge.
|
||||||
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`)
|
- **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques.
|
||||||
- Signature des binaires + JSON d'updater pour mises à jour automatiques
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,8 +171,8 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
- `save_profiles` — Sauvegarde de la configuration
|
- `save_profiles` — Sauvegarde de la configuration
|
||||||
- `delete_profile_db` — Suppression du fichier de base de données
|
- `delete_profile_db` — Suppression du fichier de base de données
|
||||||
- `get_new_profile_init_sql` — Récupération du schéma consolidé
|
- `get_new_profile_init_sql` — Récupération du schéma consolidé
|
||||||
- `hash_pin` — Hachage Argon2 du PIN
|
- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`)
|
||||||
- `verify_pin` — Vérification du PIN
|
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
|
||||||
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
||||||
|
|
||||||
## Pages et routing
|
## Pages et routing
|
||||||
|
|
|
||||||
|
|
@ -34,5 +34,14 @@ encoding_rs = "0.8"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
subtle = "2"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
machine-uid = "0.5"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`
|
||||||
|
# feature because the `LineEnding` re-export path varies between versions
|
||||||
|
# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial
|
||||||
|
# for Ed25519.
|
||||||
|
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
|
||||||
|
|
|
||||||
67
src-tauri/src/commands/entitlements.rs
Normal file
67
src-tauri/src/commands/entitlements.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Centralized feature → tier mapping for license entitlements.
|
||||||
|
//
|
||||||
|
// This module is the single source of truth for which features are gated by which tier.
|
||||||
|
// To change what is gated where, modify FEATURE_TIERS only — never sprinkle edition checks
|
||||||
|
// throughout the codebase.
|
||||||
|
|
||||||
|
/// Editions, ordered from least to most privileged.
|
||||||
|
pub const EDITION_FREE: &str = "free";
|
||||||
|
pub const EDITION_BASE: &str = "base";
|
||||||
|
pub const EDITION_PREMIUM: &str = "premium";
|
||||||
|
|
||||||
|
/// Maps feature name → list of editions allowed to use it.
|
||||||
|
/// A feature absent from this list is denied for all editions.
|
||||||
|
const FEATURE_TIERS: &[(&str, &[&str])] = &[
|
||||||
|
("auto-update", &[EDITION_BASE, EDITION_PREMIUM]),
|
||||||
|
("web-sync", &[EDITION_PREMIUM]),
|
||||||
|
("cloud-backup", &[EDITION_PREMIUM]),
|
||||||
|
("advanced-reports", &[EDITION_PREMIUM]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Pure check: does `edition` grant access to `feature`?
|
||||||
|
pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
|
||||||
|
FEATURE_TIERS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == feature)
|
||||||
|
.map(|(_, tiers)| tiers.contains(&edition))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
|
||||||
|
let edition = crate::commands::license_commands::current_edition(&app);
|
||||||
|
Ok(is_feature_allowed(&feature, &edition))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn free_blocks_auto_update() {
|
||||||
|
assert!(!is_feature_allowed("auto-update", EDITION_FREE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_unlocks_auto_update() {
|
||||||
|
assert!(is_feature_allowed("auto-update", EDITION_BASE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn premium_unlocks_everything() {
|
||||||
|
assert!(is_feature_allowed("auto-update", EDITION_PREMIUM));
|
||||||
|
assert!(is_feature_allowed("web-sync", EDITION_PREMIUM));
|
||||||
|
assert!(is_feature_allowed("cloud-backup", EDITION_PREMIUM));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_does_not_unlock_premium_features() {
|
||||||
|
assert!(!is_feature_allowed("web-sync", EDITION_BASE));
|
||||||
|
assert!(!is_feature_allowed("cloud-backup", EDITION_BASE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_feature_denied() {
|
||||||
|
assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM));
|
||||||
|
}
|
||||||
|
}
|
||||||
460
src-tauri/src/commands/license_commands.rs
Normal file
460
src-tauri/src/commands/license_commands.rs
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
// License validation, storage and reading for the Base/Premium editions.
|
||||||
|
//
|
||||||
|
// Architecture:
|
||||||
|
// - License key = "SR-BASE-<JWT>" or "SR-PREMIUM-<JWT>", JWT signed Ed25519 by the server
|
||||||
|
// - Activation token = separate JWT, also signed by the server, binds the license to a machine
|
||||||
|
// (machine_id claim must match the local machine_id). Without it, a copied license.key would
|
||||||
|
// work on any machine. Activation tokens are issued by the server in a separate flow (Issue #49).
|
||||||
|
// - Both files live in app_data_dir/ — license.key and activation.token
|
||||||
|
// - get_edition() returns "free" unless BOTH license JWT is valid (signature + exp) AND
|
||||||
|
// either there is no activation token (graceful pre-activation state) OR the activation token
|
||||||
|
// matches the local machine_id.
|
||||||
|
//
|
||||||
|
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
|
||||||
|
|
||||||
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
|
||||||
|
|
||||||
|
// Ed25519 public key for license verification.
|
||||||
|
//
|
||||||
|
// IMPORTANT: this PEM is a development placeholder taken from RFC 8410 §10.3 test vectors.
|
||||||
|
// The matching private key is publicly known, so any license signed with it offers no real
|
||||||
|
// protection. Replace this constant with the production public key before shipping a paid
|
||||||
|
// release. The corresponding private key MUST live only on the license server (Issue #49).
|
||||||
|
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
|
||||||
|
MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
|
||||||
|
-----END PUBLIC KEY-----\n";
|
||||||
|
|
||||||
|
const LICENSE_FILE: &str = "license.key";
|
||||||
|
const ACTIVATION_FILE: &str = "activation.token";
|
||||||
|
|
||||||
|
const KEY_PREFIX_BASE: &str = "SR-BASE-";
|
||||||
|
const KEY_PREFIX_PREMIUM: &str = "SR-PREMIUM-";
|
||||||
|
|
||||||
|
/// Decoded license metadata exposed to the frontend.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LicenseInfo {
|
||||||
|
pub edition: String,
|
||||||
|
pub email: String,
|
||||||
|
pub features: Vec<String>,
|
||||||
|
pub machine_limit: u32,
|
||||||
|
pub issued_at: i64,
|
||||||
|
pub expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claims embedded in the license JWT (signed by the license server).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct LicenseClaims {
|
||||||
|
sub: String, // email
|
||||||
|
iss: String,
|
||||||
|
iat: i64,
|
||||||
|
exp: i64, // mandatory — see CWE-613
|
||||||
|
edition: String,
|
||||||
|
#[serde(default)]
|
||||||
|
features: Vec<String>,
|
||||||
|
machine_limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claims embedded in the activation token JWT (server-signed, machine-bound).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct ActivationClaims {
|
||||||
|
sub: String, // license id or hash
|
||||||
|
iat: i64,
|
||||||
|
exp: i64,
|
||||||
|
machine_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
app.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("Cannot get app data dir: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn license_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app_data_dir(app)?.join(LICENSE_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activation_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app_data_dir(app)?.join(ACTIVATION_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the human-readable prefix and return the bare JWT.
|
||||||
|
fn strip_prefix(key: &str) -> Result<&str, String> {
|
||||||
|
let trimmed = key.trim();
|
||||||
|
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_BASE) {
|
||||||
|
return Ok(jwt);
|
||||||
|
}
|
||||||
|
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_PREMIUM) {
|
||||||
|
return Ok(jwt);
|
||||||
|
}
|
||||||
|
Err("License key must start with SR-BASE- or SR-PREMIUM-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `Validation` with `exp` and `iat` mandatory. Assertions are explicit so a future
|
||||||
|
/// config change cannot silently disable expiry checking (CWE-613).
|
||||||
|
fn strict_validation() -> Validation {
|
||||||
|
let mut validation = Validation::new(Algorithm::EdDSA);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.leeway = 0;
|
||||||
|
validation.set_required_spec_claims(&["exp", "iat"]);
|
||||||
|
validation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the production `DecodingKey` from the embedded PEM constant.
|
||||||
|
fn embedded_decoding_key() -> Result<DecodingKey, String> {
|
||||||
|
DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes())
|
||||||
|
.map_err(|e| format!("Invalid public key: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure validation: decode the JWT, verify signature with the provided key, ensure the
|
||||||
|
/// edition claim is one we recognize. Returns `LicenseInfo` on success.
|
||||||
|
///
|
||||||
|
/// Separated from the Tauri command so tests can pass their own key.
|
||||||
|
fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result<LicenseInfo, String> {
|
||||||
|
let jwt = strip_prefix(key)?;
|
||||||
|
let validation = strict_validation();
|
||||||
|
|
||||||
|
let data = decode::<LicenseClaims>(jwt, decoding_key, &validation)
|
||||||
|
.map_err(|e| format!("Invalid license: {}", e))?;
|
||||||
|
|
||||||
|
let claims = data.claims;
|
||||||
|
if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM {
|
||||||
|
return Err(format!("Unknown edition '{}'", claims.edition));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LicenseInfo {
|
||||||
|
edition: claims.edition,
|
||||||
|
email: claims.sub,
|
||||||
|
features: claims.features,
|
||||||
|
machine_limit: claims.machine_limit,
|
||||||
|
issued_at: claims.iat,
|
||||||
|
expires_at: claims.exp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an activation token against the local machine. The token must be signed by the
|
||||||
|
/// license server and its `machine_id` claim must match the local machine identifier.
|
||||||
|
fn validate_activation_with_key(
|
||||||
|
token: &str,
|
||||||
|
local_machine_id: &str,
|
||||||
|
decoding_key: &DecodingKey,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let validation = strict_validation();
|
||||||
|
|
||||||
|
let data = decode::<ActivationClaims>(token.trim(), decoding_key, &validation)
|
||||||
|
.map_err(|e| format!("Invalid activation token: {}", e))?;
|
||||||
|
|
||||||
|
if data.claims.machine_id != local_machine_id {
|
||||||
|
return Err("Activation token belongs to a different machine".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tauri commands ===========================================================================
|
||||||
|
|
||||||
|
/// Validate a license key without persisting it. Used by the UI to give immediate feedback
|
||||||
|
/// before the user confirms storage.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
|
||||||
|
let decoding_key = embedded_decoding_key()?;
|
||||||
|
validate_with_key(&key, &decoding_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a previously-validated license key to disk. The activation token (machine binding)
|
||||||
|
/// is stored separately by [`store_activation_token`] once the server has issued one.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
|
||||||
|
let decoding_key = embedded_decoding_key()?;
|
||||||
|
let info = validate_with_key(&key, &decoding_key)?;
|
||||||
|
let path = license_path(&app)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
||||||
|
}
|
||||||
|
fs::write(&path, key.trim()).map_err(|e| format!("Cannot write license file: {}", e))?;
|
||||||
|
Ok(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a server-issued activation token (machine binding). The token is opaque to the
|
||||||
|
/// caller — it must validate against the local machine_id to be considered active.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
|
||||||
|
let local_id = machine_id_internal()?;
|
||||||
|
let decoding_key = embedded_decoding_key()?;
|
||||||
|
validate_activation_with_key(&token, &local_id, &decoding_key)?;
|
||||||
|
let path = activation_path(&app)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
||||||
|
}
|
||||||
|
fs::write(&path, token.trim()).map_err(|e| format!("Cannot write activation file: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the stored license without revalidating. Returns `None` when no license is present.
|
||||||
|
/// The returned info is only structurally decoded — call [`get_edition`] for the gating value.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String> {
|
||||||
|
let path = license_path(&app)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?;
|
||||||
|
let Ok(decoding_key) = embedded_decoding_key() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(validate_with_key(&key, &decoding_key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates.
|
||||||
|
///
|
||||||
|
/// Returns "free" when:
|
||||||
|
/// - no license is stored,
|
||||||
|
/// - the license JWT is invalid or expired,
|
||||||
|
/// - an activation token exists but does not match this machine.
|
||||||
|
///
|
||||||
|
/// Note: a missing activation token is treated as a graceful pre-activation state and does
|
||||||
|
/// NOT downgrade the edition. Server-side activation happens later (Issue #53).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
|
Ok(current_edition(&app))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
|
||||||
|
/// failure resolves to "free" so feature gates fail closed.
|
||||||
|
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
||||||
|
let Ok(path) = license_path(app) else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
if !path.exists() {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
}
|
||||||
|
let Ok(key) = fs::read_to_string(&path) else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
let Ok(decoding_key) = embedded_decoding_key() else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
let Ok(info) = validate_with_key(&key, &decoding_key) else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If an activation token exists, it must match the local machine. A missing token is
|
||||||
|
// accepted (graceful pre-activation).
|
||||||
|
if let Ok(activation_path) = activation_path(app) {
|
||||||
|
if activation_path.exists() {
|
||||||
|
let Ok(token) = fs::read_to_string(&activation_path) else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
let Ok(local_id) = machine_id_internal() else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info.edition
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
|
||||||
|
/// or hardware migration, in which case the user must re-activate (handled in Issue #53).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_machine_id() -> Result<String, String> {
|
||||||
|
machine_id_internal()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn machine_id_internal() -> Result<String, String> {
|
||||||
|
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tests ====================================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
|
||||||
|
// === Manual DER encoder for the Ed25519 private key =======================================
|
||||||
|
// We avoid the `pem` feature on `ed25519-dalek` because the `LineEnding` re-export path
|
||||||
|
// varies across `pkcs8`/`spki`/`der` versions. The Ed25519 PKCS#8 v1 byte layout is fixed
|
||||||
|
// and trivial: 16-byte prefix + 32-byte raw seed.
|
||||||
|
//
|
||||||
|
// Note the asymmetry in jsonwebtoken's API:
|
||||||
|
// - `EncodingKey::from_ed_der` expects a PKCS#8-wrapped private key (passed to ring's
|
||||||
|
// `Ed25519KeyPair::from_pkcs8`).
|
||||||
|
// - `DecodingKey::from_ed_der` expects the *raw* 32-byte public key (passed to ring's
|
||||||
|
// `UnparsedPublicKey::new` which takes raw bytes, not a SubjectPublicKeyInfo).
|
||||||
|
|
||||||
|
/// Wrap a 32-byte Ed25519 seed in a PKCS#8 v1 PrivateKeyInfo DER blob.
|
||||||
|
fn ed25519_pkcs8_private_der(seed: &[u8; 32]) -> Vec<u8> {
|
||||||
|
// SEQUENCE(46) {
|
||||||
|
// INTEGER(1) 0 // version v1
|
||||||
|
// SEQUENCE(5) {
|
||||||
|
// OID(3) 1.3.101.112 // Ed25519
|
||||||
|
// }
|
||||||
|
// OCTET STRING(34) {
|
||||||
|
// OCTET STRING(32) <32 bytes>
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
let mut der = vec![
|
||||||
|
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
|
||||||
|
0x04, 0x20,
|
||||||
|
];
|
||||||
|
der.extend_from_slice(seed);
|
||||||
|
der
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a deterministic test keypair so signed tokens are reproducible across runs.
|
||||||
|
fn test_keys(seed: [u8; 32]) -> (EncodingKey, DecodingKey) {
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let pubkey_bytes = signing_key.verifying_key().to_bytes();
|
||||||
|
let priv_der = ed25519_pkcs8_private_der(&seed);
|
||||||
|
let encoding_key = EncodingKey::from_ed_der(&priv_der);
|
||||||
|
// Raw 32-byte public key (NOT SubjectPublicKeyInfo) — see note above.
|
||||||
|
let decoding_key = DecodingKey::from_ed_der(&pubkey_bytes);
|
||||||
|
(encoding_key, decoding_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_keys() -> (EncodingKey, DecodingKey) {
|
||||||
|
test_keys([42u8; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_token<T: serde::Serialize>(encoding_key: &EncodingKey, claims: &T) -> String {
|
||||||
|
encode(&Header::new(Algorithm::EdDSA), claims, encoding_key).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_key_without_prefix() {
|
||||||
|
let (_enc, dec) = default_keys();
|
||||||
|
let result = validate_with_key("nonsense", &dec);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_well_formed_base_license() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let claims = LicenseClaims {
|
||||||
|
sub: "user@example.com".to_string(),
|
||||||
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp: now() + 86400,
|
||||||
|
edition: EDITION_BASE.to_string(),
|
||||||
|
features: vec!["auto-update".to_string()],
|
||||||
|
machine_limit: 3,
|
||||||
|
};
|
||||||
|
let jwt = make_token(&enc, &claims);
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
|
let info = validate_with_key(&key, &dec).unwrap();
|
||||||
|
assert_eq!(info.edition, EDITION_BASE);
|
||||||
|
assert_eq!(info.email, "user@example.com");
|
||||||
|
assert_eq!(info.machine_limit, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_expired_license() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let claims = LicenseClaims {
|
||||||
|
sub: "user@example.com".to_string(),
|
||||||
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
iat: now() - 1000,
|
||||||
|
exp: now() - 100,
|
||||||
|
edition: EDITION_BASE.to_string(),
|
||||||
|
features: vec![],
|
||||||
|
machine_limit: 3,
|
||||||
|
};
|
||||||
|
let jwt = make_token(&enc, &claims);
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
|
let result = validate_with_key(&key, &dec);
|
||||||
|
assert!(result.is_err(), "expired license must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_license_signed_with_wrong_key() {
|
||||||
|
let (enc_signer, _dec_signer) = default_keys();
|
||||||
|
let (_enc_other, dec_other) = test_keys([7u8; 32]);
|
||||||
|
let claims = LicenseClaims {
|
||||||
|
sub: "user@example.com".to_string(),
|
||||||
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp: now() + 86400,
|
||||||
|
edition: EDITION_BASE.to_string(),
|
||||||
|
features: vec![],
|
||||||
|
machine_limit: 3,
|
||||||
|
};
|
||||||
|
let jwt = make_token(&enc_signer, &claims);
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
|
let result = validate_with_key(&key, &dec_other);
|
||||||
|
assert!(result.is_err(), "wrong-key signature must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_corrupted_jwt() {
|
||||||
|
let (_enc, dec) = default_keys();
|
||||||
|
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
|
||||||
|
let result = validate_with_key(&key, &dec);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_edition() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let claims = LicenseClaims {
|
||||||
|
sub: "user@example.com".to_string(),
|
||||||
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp: now() + 86400,
|
||||||
|
edition: "enterprise".to_string(),
|
||||||
|
features: vec![],
|
||||||
|
machine_limit: 3,
|
||||||
|
};
|
||||||
|
let jwt = make_token(&enc, &claims);
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
|
let result = validate_with_key(&key, &dec);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activation_token_matches_machine() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let claims = ActivationClaims {
|
||||||
|
sub: "license-id".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp: now() + 86400,
|
||||||
|
machine_id: "this-machine".to_string(),
|
||||||
|
};
|
||||||
|
let token = make_token(&enc, &claims);
|
||||||
|
assert!(validate_activation_with_key(&token, "this-machine", &dec).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activation_token_rejects_other_machine() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let claims = ActivationClaims {
|
||||||
|
sub: "license-id".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp: now() + 86400,
|
||||||
|
machine_id: "machine-A".to_string(),
|
||||||
|
};
|
||||||
|
let token = make_token(&enc, &claims);
|
||||||
|
let result = validate_activation_with_key(&token, "machine-B", &dec);
|
||||||
|
assert!(result.is_err(), "copied activation token must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_public_key_pem_parses() {
|
||||||
|
// Sanity check that the production PEM constant is well-formed.
|
||||||
|
assert!(embedded_decoding_key().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
pub mod fs_commands;
|
pub mod entitlements;
|
||||||
pub mod export_import_commands;
|
pub mod export_import_commands;
|
||||||
|
pub mod fs_commands;
|
||||||
|
pub mod license_commands;
|
||||||
pub mod profile_commands;
|
pub mod profile_commands;
|
||||||
|
|
||||||
pub use fs_commands::*;
|
pub use entitlements::*;
|
||||||
pub use export_import_commands::*;
|
pub use export_import_commands::*;
|
||||||
|
pub use fs_commands::*;
|
||||||
|
pub use license_commands::*;
|
||||||
pub use profile_commands::*;
|
pub use profile_commands::*;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256, Sha384};
|
use sha2::{Digest, Sha256, Sha384};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use crate::database;
|
use crate::database;
|
||||||
|
|
@ -118,44 +120,103 @@ pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
// Argon2id parameters for PIN hashing (same as export_import_commands.rs)
|
||||||
pub fn hash_pin(pin: String) -> Result<String, String> {
|
const ARGON2_M_COST: u32 = 65536; // 64 MiB
|
||||||
let mut salt = [0u8; 16];
|
const ARGON2_T_COST: u32 = 3;
|
||||||
rand::rngs::OsRng.fill_bytes(&mut salt);
|
const ARGON2_P_COST: u32 = 1;
|
||||||
let salt_hex = hex_encode(&salt);
|
const ARGON2_OUTPUT_LEN: usize = 32;
|
||||||
|
const ARGON2_SALT_LEN: usize = 16;
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
fn argon2_hash(pin: &str, salt: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
hasher.update(salt_hex.as_bytes());
|
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN))
|
||||||
hasher.update(pin.as_bytes());
|
.map_err(|e| format!("Argon2 params error: {}", e))?;
|
||||||
let result = hasher.finalize();
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
let hash_hex = hex_encode(&result);
|
let mut hash = vec![0u8; ARGON2_OUTPUT_LEN];
|
||||||
|
argon2
|
||||||
// Store as "salt:hash"
|
.hash_password_into(pin.as_bytes(), salt, &mut hash)
|
||||||
Ok(format!("{}:{}", salt_hex, hash_hex))
|
.map_err(|e| format!("Argon2 hash error: {}", e))?;
|
||||||
|
Ok(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
|
pub fn hash_pin(pin: String) -> Result<String, String> {
|
||||||
|
let mut salt = [0u8; ARGON2_SALT_LEN];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut salt);
|
||||||
|
let salt_hex = hex_encode(&salt);
|
||||||
|
|
||||||
|
let hash = argon2_hash(&pin, &salt)?;
|
||||||
|
let hash_hex = hex_encode(&hash);
|
||||||
|
|
||||||
|
// Store as "argon2id:salt:hash" to distinguish from legacy SHA-256 "salt:hash"
|
||||||
|
Ok(format!("argon2id:{}:{}", salt_hex, hash_hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VerifyPinResult {
|
||||||
|
pub valid: bool,
|
||||||
|
/// New Argon2id hash when a legacy SHA-256 PIN was successfully verified and re-hashed
|
||||||
|
pub rehashed: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn verify_pin(pin: String, stored_hash: String) -> Result<VerifyPinResult, String> {
|
||||||
|
// Argon2id format: "argon2id:salt_hex:hash_hex"
|
||||||
|
if let Some(rest) = stored_hash.strip_prefix("argon2id:") {
|
||||||
|
let parts: Vec<&str> = rest.split(':').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err("Invalid Argon2id hash format".to_string());
|
||||||
|
}
|
||||||
|
let salt = hex_decode(parts[0])?;
|
||||||
|
let expected_hash = hex_decode(parts[1])?;
|
||||||
|
|
||||||
|
let computed = argon2_hash(&pin, &salt)?;
|
||||||
|
|
||||||
|
let valid = computed.ct_eq(&expected_hash).into();
|
||||||
|
return Ok(VerifyPinResult { valid, rehashed: None });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy SHA-256 format: "salt_hex:hash_hex"
|
||||||
let parts: Vec<&str> = stored_hash.split(':').collect();
|
let parts: Vec<&str> = stored_hash.split(':').collect();
|
||||||
if parts.len() != 2 {
|
if parts.len() != 2 {
|
||||||
return Err("Invalid stored hash format".to_string());
|
return Err("Invalid stored hash format".to_string());
|
||||||
}
|
}
|
||||||
let salt_hex = parts[0];
|
let salt_hex = parts[0];
|
||||||
let expected_hash = parts[1];
|
let expected_hash = hex_decode(parts[1])?;
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(salt_hex.as_bytes());
|
hasher.update(salt_hex.as_bytes());
|
||||||
hasher.update(pin.as_bytes());
|
hasher.update(pin.as_bytes());
|
||||||
let result = hasher.finalize();
|
let result = hasher.finalize();
|
||||||
let computed_hash = hex_encode(&result);
|
|
||||||
|
|
||||||
Ok(computed_hash == expected_hash)
|
let valid: bool = result.as_slice().ct_eq(&expected_hash).into();
|
||||||
|
|
||||||
|
if valid {
|
||||||
|
// Re-hash with Argon2id so this legacy PIN is upgraded.
|
||||||
|
// If rehash fails, still allow login — don't block the user.
|
||||||
|
let rehashed = hash_pin(pin).ok();
|
||||||
|
Ok(VerifyPinResult { valid: true, rehashed })
|
||||||
|
} else {
|
||||||
|
Ok(VerifyPinResult { valid: false, rehashed: None })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hex_encode(bytes: &[u8]) -> String {
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
|
||||||
|
if hex.len() % 2 != 0 {
|
||||||
|
return Err("Invalid hex string length".to_string());
|
||||||
|
}
|
||||||
|
(0..hex.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| {
|
||||||
|
u8::from_str_radix(&hex[i..i + 2], 16)
|
||||||
|
.map_err(|e| format!("Invalid hex character: {}", e))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Repair migration checksums for a profile database.
|
/// Repair migration checksums for a profile database.
|
||||||
/// Updates stored checksums to match current migration SQL, avoiding re-application
|
/// Updates stored checksums to match current migration SQL, avoiding re-application
|
||||||
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
|
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
|
||||||
|
|
@ -217,3 +278,98 @@ pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<b
|
||||||
|
|
||||||
Ok(repaired)
|
Ok(repaired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_pin_produces_argon2id_format() {
|
||||||
|
let hash = hash_pin("1234".to_string()).unwrap();
|
||||||
|
assert!(hash.starts_with("argon2id:"), "Hash should start with 'argon2id:' prefix");
|
||||||
|
let parts: Vec<&str> = hash.split(':').collect();
|
||||||
|
assert_eq!(parts.len(), 3, "Hash should have 3 parts: prefix:salt:hash");
|
||||||
|
assert_eq!(parts[1].len(), ARGON2_SALT_LEN * 2, "Salt should be {} hex chars", ARGON2_SALT_LEN * 2);
|
||||||
|
assert_eq!(parts[2].len(), ARGON2_OUTPUT_LEN * 2, "Hash should be {} hex chars", ARGON2_OUTPUT_LEN * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_pin_different_salts() {
|
||||||
|
let h1 = hash_pin("1234".to_string()).unwrap();
|
||||||
|
let h2 = hash_pin("1234".to_string()).unwrap();
|
||||||
|
assert_ne!(h1, h2, "Two hashes of the same PIN should use different salts");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_argon2id_pin_correct() {
|
||||||
|
let hash = hash_pin("5678".to_string()).unwrap();
|
||||||
|
let result = verify_pin("5678".to_string(), hash).unwrap();
|
||||||
|
assert!(result.valid, "Correct PIN should verify");
|
||||||
|
assert!(result.rehashed.is_none(), "Argon2id PIN should not be rehashed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_argon2id_pin_wrong() {
|
||||||
|
let hash = hash_pin("5678".to_string()).unwrap();
|
||||||
|
let result = verify_pin("0000".to_string(), hash).unwrap();
|
||||||
|
assert!(!result.valid, "Wrong PIN should not verify");
|
||||||
|
assert!(result.rehashed.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_legacy_sha256_correct_and_rehash() {
|
||||||
|
// Create a legacy SHA-256 hash: "salt_hex:sha256(salt_hex + pin)"
|
||||||
|
let salt_hex = "abcdef0123456789";
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(salt_hex.as_bytes());
|
||||||
|
hasher.update(b"4321");
|
||||||
|
let hash_bytes = hasher.finalize();
|
||||||
|
let hash_hex = hex_encode(&hash_bytes);
|
||||||
|
let stored = format!("{}:{}", salt_hex, hash_hex);
|
||||||
|
|
||||||
|
let result = verify_pin("4321".to_string(), stored).unwrap();
|
||||||
|
assert!(result.valid, "Correct legacy PIN should verify");
|
||||||
|
assert!(result.rehashed.is_some(), "Legacy PIN should be rehashed to Argon2id");
|
||||||
|
|
||||||
|
// Verify the rehashed value is a valid Argon2id hash
|
||||||
|
let new_hash = result.rehashed.unwrap();
|
||||||
|
assert!(new_hash.starts_with("argon2id:"));
|
||||||
|
|
||||||
|
// Verify the rehashed value works for future verification
|
||||||
|
let result2 = verify_pin("4321".to_string(), new_hash).unwrap();
|
||||||
|
assert!(result2.valid, "Rehashed PIN should verify");
|
||||||
|
assert!(result2.rehashed.is_none(), "Already Argon2id, no rehash needed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_legacy_sha256_wrong() {
|
||||||
|
let salt_hex = "abcdef0123456789";
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(salt_hex.as_bytes());
|
||||||
|
hasher.update(b"4321");
|
||||||
|
let hash_bytes = hasher.finalize();
|
||||||
|
let hash_hex = hex_encode(&hash_bytes);
|
||||||
|
let stored = format!("{}:{}", salt_hex, hash_hex);
|
||||||
|
|
||||||
|
let result = verify_pin("9999".to_string(), stored).unwrap();
|
||||||
|
assert!(!result.valid, "Wrong legacy PIN should not verify");
|
||||||
|
assert!(result.rehashed.is_none(), "Failed verification should not rehash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_invalid_format() {
|
||||||
|
let result = verify_pin("1234".to_string(), "invalid".to_string());
|
||||||
|
assert!(result.is_err(), "Single-part hash should fail");
|
||||||
|
|
||||||
|
let result = verify_pin("1234".to_string(), "argon2id:bad".to_string());
|
||||||
|
assert!(result.is_err(), "Argon2id with wrong part count should fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hex_roundtrip() {
|
||||||
|
let original = vec![0u8, 127, 255, 1, 16];
|
||||||
|
let encoded = hex_encode(&original);
|
||||||
|
let decoded = hex_decode(&encoded).unwrap();
|
||||||
|
assert_eq!(original, decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,13 @@ pub fn run() {
|
||||||
commands::hash_pin,
|
commands::hash_pin,
|
||||||
commands::verify_pin,
|
commands::verify_pin,
|
||||||
commands::repair_migrations,
|
commands::repair_migrations,
|
||||||
|
commands::validate_license_key,
|
||||||
|
commands::store_license,
|
||||||
|
commands::store_activation_token,
|
||||||
|
commands::read_license,
|
||||||
|
commands::get_edition,
|
||||||
|
commands::get_machine_id,
|
||||||
|
commands::check_entitlement,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { verifyPin } from "../../services/profileService";
|
||||||
interface Props {
|
interface Props {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
storedHash: string;
|
storedHash: string;
|
||||||
onSuccess: () => void;
|
onSuccess: (rehashed?: string | null) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,9 +41,9 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
|
||||||
if (value && filledCount === index + 1) {
|
if (value && filledCount === index + 1) {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
try {
|
try {
|
||||||
const valid = await verifyPin(pin.replace(/\s/g, ""), storedHash);
|
const result = await verifyPin(pin.replace(/\s/g, ""), storedHash);
|
||||||
if (valid) {
|
if (result.valid) {
|
||||||
onSuccess();
|
onSuccess(result.rehashed);
|
||||||
} else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) {
|
} else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) {
|
||||||
setError(true);
|
setError(true);
|
||||||
setDigits(["", "", "", "", "", ""]);
|
setDigits(["", "", "", "", "", ""]);
|
||||||
|
|
@ -67,10 +67,10 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
|
||||||
const pin = digits.join("");
|
const pin = digits.join("");
|
||||||
if (pin.length >= 4) {
|
if (pin.length >= 4) {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
verifyPin(pin, storedHash).then((valid) => {
|
verifyPin(pin, storedHash).then((result) => {
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
if (valid) {
|
if (result.valid) {
|
||||||
onSuccess();
|
onSuccess(result.rehashed);
|
||||||
} else {
|
} else {
|
||||||
setError(true);
|
setError(true);
|
||||||
setDigits(["", "", "", "", "", ""]);
|
setDigits(["", "", "", "", "", ""]);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { Profile } from "../../services/profileService";
|
||||||
|
|
||||||
export default function ProfileSwitcher() {
|
export default function ProfileSwitcher() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { profiles, activeProfile, switchProfile } = useProfile();
|
const { profiles, activeProfile, switchProfile, updateProfile } = useProfile();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
|
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
|
||||||
const [showManage, setShowManage] = useState(false);
|
const [showManage, setShowManage] = useState(false);
|
||||||
|
|
@ -36,8 +36,15 @@ export default function ProfileSwitcher() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePinSuccess = () => {
|
const handlePinSuccess = async (rehashed?: string | null) => {
|
||||||
if (pinProfile) {
|
if (pinProfile) {
|
||||||
|
if (rehashed) {
|
||||||
|
try {
|
||||||
|
await updateProfile(pinProfile.id, { pin_hash: rehashed });
|
||||||
|
} catch {
|
||||||
|
// Best-effort rehash: don't block profile switch if persistence fails
|
||||||
|
}
|
||||||
|
}
|
||||||
switchProfile(pinProfile.id);
|
switchProfile(pinProfile.id);
|
||||||
setPinProfile(null);
|
setPinProfile(null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ interface ProfileContextValue {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
switchProfile: (id: string) => Promise<void>;
|
switchProfile: (id: string) => Promise<void>;
|
||||||
createProfile: (name: string, color: string, pin?: string) => Promise<void>;
|
createProfile: (name: string, color: string, pin?: string) => Promise<void>;
|
||||||
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => Promise<void>;
|
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => Promise<void>;
|
||||||
deleteProfile: (id: string) => Promise<void>;
|
deleteProfile: (id: string) => Promise<void>;
|
||||||
setPin: (id: string, pin: string | null) => Promise<void>;
|
setPin: (id: string, pin: string | null) => Promise<void>;
|
||||||
connectActiveProfile: () => Promise<void>;
|
connectActiveProfile: () => Promise<void>;
|
||||||
|
|
@ -151,7 +151,7 @@ export function ProfileProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [state.config]);
|
}, [state.config]);
|
||||||
|
|
||||||
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => {
|
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => {
|
||||||
if (!state.config) return;
|
if (!state.config) return;
|
||||||
|
|
||||||
const newProfiles = state.config.profiles.map((p) =>
|
const newProfiles = state.config.profiles.map((p) =>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import ProfileFormModal from "../components/profile/ProfileFormModal";
|
||||||
|
|
||||||
export default function ProfileSelectionPage() {
|
export default function ProfileSelectionPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { profiles, switchProfile } = useProfile();
|
const { profiles, switchProfile, updateProfile } = useProfile();
|
||||||
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
|
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
|
@ -23,8 +23,15 @@ export default function ProfileSelectionPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePinSuccess = () => {
|
const handlePinSuccess = async (rehashed?: string | null) => {
|
||||||
if (pinProfileId) {
|
if (pinProfileId) {
|
||||||
|
if (rehashed) {
|
||||||
|
try {
|
||||||
|
await updateProfile(pinProfileId, { pin_hash: rehashed });
|
||||||
|
} catch {
|
||||||
|
// Best-effort rehash: don't block profile switch if persistence fails
|
||||||
|
}
|
||||||
|
}
|
||||||
switchProfile(pinProfileId);
|
switchProfile(pinProfileId);
|
||||||
setPinProfileId(null);
|
setPinProfileId(null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ export async function hashPin(pin: string): Promise<string> {
|
||||||
return invoke<string>("hash_pin", { pin });
|
return invoke<string>("hash_pin", { pin });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyPin(pin: string, storedHash: string): Promise<boolean> {
|
export interface VerifyPinResult {
|
||||||
return invoke<boolean>("verify_pin", { pin, storedHash });
|
valid: boolean;
|
||||||
|
/** New Argon2id hash when a legacy SHA-256 PIN was re-hashed on successful verification */
|
||||||
|
rehashed: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPin(pin: string, storedHash: string): Promise<VerifyPinResult> {
|
||||||
|
return invoke<VerifyPinResult>("verify_pin", { pin, storedHash });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue