Compare commits
2 commits
2da2de183a
...
a6ffd2c4c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6ffd2c4c4 | ||
|
|
a9eacc8b9a |
7 changed files with 78 additions and 282 deletions
|
|
@ -1,97 +0,0 @@
|
||||||
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
68
.github/workflows/check.yml
vendored
|
|
@ -1,68 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
### Ajouté
|
### 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)
|
|
||||||
- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47)
|
- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47)
|
||||||
|
|
||||||
## [0.6.7] - 2026-03-29
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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)
|
|
||||||
- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47)
|
- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47)
|
||||||
|
|
||||||
## [0.6.7] - 2026-03-29
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,9 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
- **`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.
|
- GitHub Actions (`release.yml`) déclenché par tags `v*`
|
||||||
- **`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.
|
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`)
|
||||||
|
- Signature des binaires + JSON d'updater pour mises à jour automatiques
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,4 @@ jsonwebtoken = "9"
|
||||||
machine-uid = "0.5"
|
machine-uid = "0.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`
|
ed25519-dalek = { version = "2", features = ["pkcs8", "pem", "rand_core"] }
|
||||||
# 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"] }
|
|
||||||
|
|
|
||||||
|
|
@ -95,31 +95,24 @@ fn strip_prefix(key: &str) -> Result<&str, String> {
|
||||||
Err("License key must start with SR-BASE- or SR-PREMIUM-".to_string())
|
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
|
/// Pure validation: decode the JWT, verify signature against `public_key_pem`, ensure the
|
||||||
/// 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.
|
/// edition claim is one we recognize. Returns `LicenseInfo` on success.
|
||||||
///
|
///
|
||||||
/// Separated from the Tauri command so tests can pass their own key.
|
/// Separated from the Tauri command so tests can pass their own key.
|
||||||
fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result<LicenseInfo, String> {
|
fn validate_with_key(key: &str, public_key_pem: &[u8]) -> Result<LicenseInfo, String> {
|
||||||
let jwt = strip_prefix(key)?;
|
let jwt = strip_prefix(key)?;
|
||||||
let validation = strict_validation();
|
|
||||||
|
|
||||||
let data = decode::<LicenseClaims>(jwt, decoding_key, &validation)
|
let decoding_key = DecodingKey::from_ed_pem(public_key_pem)
|
||||||
|
.map_err(|e| format!("Invalid public key: {}", e))?;
|
||||||
|
|
||||||
|
let mut validation = Validation::new(Algorithm::EdDSA);
|
||||||
|
// jsonwebtoken validates `exp` by default; assert this explicitly so a future config
|
||||||
|
// change cannot silently disable it (CWE-613).
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.leeway = 0;
|
||||||
|
validation.set_required_spec_claims(&["exp", "iat"]);
|
||||||
|
|
||||||
|
let data = decode::<LicenseClaims>(jwt, &decoding_key, &validation)
|
||||||
.map_err(|e| format!("Invalid license: {}", e))?;
|
.map_err(|e| format!("Invalid license: {}", e))?;
|
||||||
|
|
||||||
let claims = data.claims;
|
let claims = data.claims;
|
||||||
|
|
@ -142,11 +135,17 @@ fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result<LicenseInf
|
||||||
fn validate_activation_with_key(
|
fn validate_activation_with_key(
|
||||||
token: &str,
|
token: &str,
|
||||||
local_machine_id: &str,
|
local_machine_id: &str,
|
||||||
decoding_key: &DecodingKey,
|
public_key_pem: &[u8],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let validation = strict_validation();
|
let decoding_key = DecodingKey::from_ed_pem(public_key_pem)
|
||||||
|
.map_err(|e| format!("Invalid public key: {}", e))?;
|
||||||
|
|
||||||
let data = decode::<ActivationClaims>(token.trim(), decoding_key, &validation)
|
let mut validation = Validation::new(Algorithm::EdDSA);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.leeway = 0;
|
||||||
|
validation.set_required_spec_claims(&["exp", "iat"]);
|
||||||
|
|
||||||
|
let data = decode::<ActivationClaims>(token.trim(), &decoding_key, &validation)
|
||||||
.map_err(|e| format!("Invalid activation token: {}", e))?;
|
.map_err(|e| format!("Invalid activation token: {}", e))?;
|
||||||
|
|
||||||
if data.claims.machine_id != local_machine_id {
|
if data.claims.machine_id != local_machine_id {
|
||||||
|
|
@ -161,16 +160,14 @@ fn validate_activation_with_key(
|
||||||
/// before the user confirms storage.
|
/// before the user confirms storage.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
|
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
|
||||||
let decoding_key = embedded_decoding_key()?;
|
validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes())
|
||||||
validate_with_key(&key, &decoding_key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist a previously-validated license key to disk. The activation token (machine binding)
|
/// 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.
|
/// is stored separately by [`store_activation_token`] once the server has issued one.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
|
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
|
||||||
let decoding_key = embedded_decoding_key()?;
|
let info = validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes())?;
|
||||||
let info = validate_with_key(&key, &decoding_key)?;
|
|
||||||
let path = license_path(&app)?;
|
let path = license_path(&app)?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
||||||
|
|
@ -184,8 +181,7 @@ pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo,
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
|
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
|
||||||
let local_id = machine_id_internal()?;
|
let local_id = machine_id_internal()?;
|
||||||
let decoding_key = embedded_decoding_key()?;
|
validate_activation_with_key(&token, &local_id, PUBLIC_KEY_PEM.as_bytes())?;
|
||||||
validate_activation_with_key(&token, &local_id, &decoding_key)?;
|
|
||||||
let path = activation_path(&app)?;
|
let path = activation_path(&app)?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
||||||
|
|
@ -202,10 +198,7 @@ pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?;
|
let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?;
|
||||||
let Ok(decoding_key) = embedded_decoding_key() else {
|
Ok(validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes()).ok())
|
||||||
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 the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates.
|
||||||
|
|
@ -234,10 +227,7 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
||||||
let Ok(key) = fs::read_to_string(&path) else {
|
let Ok(key) = fs::read_to_string(&path) else {
|
||||||
return EDITION_FREE.to_string();
|
return EDITION_FREE.to_string();
|
||||||
};
|
};
|
||||||
let Ok(decoding_key) = embedded_decoding_key() else {
|
let Ok(info) = validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes()) else {
|
||||||
return EDITION_FREE.to_string();
|
|
||||||
};
|
|
||||||
let Ok(info) = validate_with_key(&key, &decoding_key) else {
|
|
||||||
return EDITION_FREE.to_string();
|
return EDITION_FREE.to_string();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -251,7 +241,8 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
||||||
let Ok(local_id) = machine_id_internal() else {
|
let Ok(local_id) = machine_id_internal() else {
|
||||||
return EDITION_FREE.to_string();
|
return EDITION_FREE.to_string();
|
||||||
};
|
};
|
||||||
if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() {
|
if validate_activation_with_key(&token, &local_id, PUBLIC_KEY_PEM.as_bytes()).is_err()
|
||||||
|
{
|
||||||
return EDITION_FREE.to_string();
|
return EDITION_FREE.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -276,56 +267,31 @@ fn machine_id_internal() -> Result<String, String> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
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.
|
/// Build a deterministic test keypair so signed tokens are reproducible across runs.
|
||||||
fn test_keys(seed: [u8; 32]) -> (EncodingKey, DecodingKey) {
|
fn test_keypair() -> (String, String) {
|
||||||
|
let seed = [42u8; 32];
|
||||||
let signing_key = SigningKey::from_bytes(&seed);
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
let pubkey_bytes = signing_key.verifying_key().to_bytes();
|
let verifying_key = signing_key.verifying_key();
|
||||||
let priv_der = ed25519_pkcs8_private_der(&seed);
|
let private_pem = signing_key
|
||||||
let encoding_key = EncodingKey::from_ed_der(&priv_der);
|
.to_pkcs8_pem(LineEnding::LF)
|
||||||
// Raw 32-byte public key (NOT SubjectPublicKeyInfo) — see note above.
|
.unwrap()
|
||||||
let decoding_key = DecodingKey::from_ed_der(&pubkey_bytes);
|
.to_string();
|
||||||
(encoding_key, decoding_key)
|
let public_pem = verifying_key.to_public_key_pem(LineEnding::LF).unwrap();
|
||||||
|
(private_pem, public_pem)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_keys() -> (EncodingKey, DecodingKey) {
|
fn make_token(private_pem: &str, claims: &LicenseClaims) -> String {
|
||||||
test_keys([42u8; 32])
|
let key = EncodingKey::from_ed_pem(private_pem.as_bytes()).unwrap();
|
||||||
|
encode(&Header::new(Algorithm::EdDSA), claims, &key).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_token<T: serde::Serialize>(encoding_key: &EncodingKey, claims: &T) -> String {
|
fn make_activation_token(private_pem: &str, claims: &ActivationClaims) -> String {
|
||||||
encode(&Header::new(Algorithm::EdDSA), claims, encoding_key).unwrap()
|
let key = EncodingKey::from_ed_pem(private_pem.as_bytes()).unwrap();
|
||||||
|
encode(&Header::new(Algorithm::EdDSA), claims, &key).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now() -> i64 {
|
fn now() -> i64 {
|
||||||
|
|
@ -337,14 +303,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_key_without_prefix() {
|
fn rejects_key_without_prefix() {
|
||||||
let (_enc, dec) = default_keys();
|
let (_priv, public_pem) = test_keypair();
|
||||||
let result = validate_with_key("nonsense", &dec);
|
let result = validate_with_key("nonsense", public_pem.as_bytes());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_well_formed_base_license() {
|
fn accepts_well_formed_base_license() {
|
||||||
let (enc, dec) = default_keys();
|
let (private_pem, public_pem) = test_keypair();
|
||||||
let claims = LicenseClaims {
|
let claims = LicenseClaims {
|
||||||
sub: "user@example.com".to_string(),
|
sub: "user@example.com".to_string(),
|
||||||
iss: "lacompagniemaximus.com".to_string(),
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
|
@ -354,9 +320,9 @@ mod tests {
|
||||||
features: vec!["auto-update".to_string()],
|
features: vec!["auto-update".to_string()],
|
||||||
machine_limit: 3,
|
machine_limit: 3,
|
||||||
};
|
};
|
||||||
let jwt = make_token(&enc, &claims);
|
let jwt = make_token(&private_pem, &claims);
|
||||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
let info = validate_with_key(&key, &dec).unwrap();
|
let info = validate_with_key(&key, public_pem.as_bytes()).unwrap();
|
||||||
assert_eq!(info.edition, EDITION_BASE);
|
assert_eq!(info.edition, EDITION_BASE);
|
||||||
assert_eq!(info.email, "user@example.com");
|
assert_eq!(info.email, "user@example.com");
|
||||||
assert_eq!(info.machine_limit, 3);
|
assert_eq!(info.machine_limit, 3);
|
||||||
|
|
@ -364,7 +330,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_expired_license() {
|
fn rejects_expired_license() {
|
||||||
let (enc, dec) = default_keys();
|
let (private_pem, public_pem) = test_keypair();
|
||||||
let claims = LicenseClaims {
|
let claims = LicenseClaims {
|
||||||
sub: "user@example.com".to_string(),
|
sub: "user@example.com".to_string(),
|
||||||
iss: "lacompagniemaximus.com".to_string(),
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
|
@ -374,16 +340,22 @@ mod tests {
|
||||||
features: vec![],
|
features: vec![],
|
||||||
machine_limit: 3,
|
machine_limit: 3,
|
||||||
};
|
};
|
||||||
let jwt = make_token(&enc, &claims);
|
let jwt = make_token(&private_pem, &claims);
|
||||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
let result = validate_with_key(&key, &dec);
|
let result = validate_with_key(&key, public_pem.as_bytes());
|
||||||
assert!(result.is_err(), "expired license must be rejected");
|
assert!(result.is_err(), "expired license must be rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_license_signed_with_wrong_key() {
|
fn rejects_license_signed_with_wrong_key() {
|
||||||
let (enc_signer, _dec_signer) = default_keys();
|
let (private_pem, _public_pem) = test_keypair();
|
||||||
let (_enc_other, dec_other) = test_keys([7u8; 32]);
|
// Generate a different keypair to verify with
|
||||||
|
let other_seed = [7u8; 32];
|
||||||
|
let other_signing = SigningKey::from_bytes(&other_seed);
|
||||||
|
let other_public = other_signing
|
||||||
|
.verifying_key()
|
||||||
|
.to_public_key_pem(LineEnding::LF)
|
||||||
|
.unwrap();
|
||||||
let claims = LicenseClaims {
|
let claims = LicenseClaims {
|
||||||
sub: "user@example.com".to_string(),
|
sub: "user@example.com".to_string(),
|
||||||
iss: "lacompagniemaximus.com".to_string(),
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
|
@ -393,23 +365,23 @@ mod tests {
|
||||||
features: vec![],
|
features: vec![],
|
||||||
machine_limit: 3,
|
machine_limit: 3,
|
||||||
};
|
};
|
||||||
let jwt = make_token(&enc_signer, &claims);
|
let jwt = make_token(&private_pem, &claims);
|
||||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
let result = validate_with_key(&key, &dec_other);
|
let result = validate_with_key(&key, other_public.as_bytes());
|
||||||
assert!(result.is_err(), "wrong-key signature must be rejected");
|
assert!(result.is_err(), "wrong-key signature must be rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_corrupted_jwt() {
|
fn rejects_corrupted_jwt() {
|
||||||
let (_enc, dec) = default_keys();
|
let (_priv, public_pem) = test_keypair();
|
||||||
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
|
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
|
||||||
let result = validate_with_key(&key, &dec);
|
let result = validate_with_key(&key, public_pem.as_bytes());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_edition() {
|
fn rejects_unknown_edition() {
|
||||||
let (enc, dec) = default_keys();
|
let (private_pem, public_pem) = test_keypair();
|
||||||
let claims = LicenseClaims {
|
let claims = LicenseClaims {
|
||||||
sub: "user@example.com".to_string(),
|
sub: "user@example.com".to_string(),
|
||||||
iss: "lacompagniemaximus.com".to_string(),
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
|
@ -419,42 +391,36 @@ mod tests {
|
||||||
features: vec![],
|
features: vec![],
|
||||||
machine_limit: 3,
|
machine_limit: 3,
|
||||||
};
|
};
|
||||||
let jwt = make_token(&enc, &claims);
|
let jwt = make_token(&private_pem, &claims);
|
||||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
let result = validate_with_key(&key, &dec);
|
let result = validate_with_key(&key, public_pem.as_bytes());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activation_token_matches_machine() {
|
fn activation_token_matches_machine() {
|
||||||
let (enc, dec) = default_keys();
|
let (private_pem, public_pem) = test_keypair();
|
||||||
let claims = ActivationClaims {
|
let claims = ActivationClaims {
|
||||||
sub: "license-id".to_string(),
|
sub: "license-id".to_string(),
|
||||||
iat: now(),
|
iat: now(),
|
||||||
exp: now() + 86400,
|
exp: now() + 86400,
|
||||||
machine_id: "this-machine".to_string(),
|
machine_id: "this-machine".to_string(),
|
||||||
};
|
};
|
||||||
let token = make_token(&enc, &claims);
|
let token = make_activation_token(&private_pem, &claims);
|
||||||
assert!(validate_activation_with_key(&token, "this-machine", &dec).is_ok());
|
assert!(validate_activation_with_key(&token, "this-machine", public_pem.as_bytes()).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activation_token_rejects_other_machine() {
|
fn activation_token_rejects_other_machine() {
|
||||||
let (enc, dec) = default_keys();
|
let (private_pem, public_pem) = test_keypair();
|
||||||
let claims = ActivationClaims {
|
let claims = ActivationClaims {
|
||||||
sub: "license-id".to_string(),
|
sub: "license-id".to_string(),
|
||||||
iat: now(),
|
iat: now(),
|
||||||
exp: now() + 86400,
|
exp: now() + 86400,
|
||||||
machine_id: "machine-A".to_string(),
|
machine_id: "machine-A".to_string(),
|
||||||
};
|
};
|
||||||
let token = make_token(&enc, &claims);
|
let token = make_activation_token(&private_pem, &claims);
|
||||||
let result = validate_activation_with_key(&token, "machine-B", &dec);
|
let result = validate_activation_with_key(&token, "machine-B", public_pem.as_bytes());
|
||||||
assert!(result.is_err(), "copied activation token must be rejected");
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue