fix(rust): use DER-built keys in license tests, drop ed25519-dalek pem feature
cargo CI flagged: `unresolved import ed25519_dalek::pkcs8::LineEnding`. The `LineEnding` re-export path varies between pkcs8/spki/der versions, so the test code that called `to_pkcs8_pem(LineEnding::LF)` won't compile against the dependency tree we get with ed25519-dalek 2.2 + pkcs8 0.10. Fix: - Drop the `pem` feature from the ed25519-dalek dev-dependency. - In tests, build the PKCS#8 v1 PrivateKeyInfo and SubjectPublicKeyInfo DER blobs manually from the raw 32-byte Ed25519 seed/public key. The Ed25519 layout is fixed (16-byte prefix + 32-byte key) so this is short and stable. - Pass the resulting DER bytes to `EncodingKey::from_ed_der` / `DecodingKey::from_ed_der`. Refactor: - Extract `strict_validation()` and `embedded_decoding_key()` helpers so the validation config (mandatory exp/iat for CWE-613) lives in one place and production callers all share the same DecodingKey constructor. - `validate_with_key` and `validate_activation_with_key` now take a `&DecodingKey` instead of raw PEM bytes; production builds the key once via `embedded_decoding_key()`. - New canary test `embedded_public_key_pem_parses` fails fast if the embedded PEM constant ever becomes malformed.
This commit is contained in:
parent
99fef19a6b
commit
69e136cab0
2 changed files with 121 additions and 75 deletions
|
|
@ -39,4 +39,8 @@ jsonwebtoken = "9"
|
|||
machine-uid = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
ed25519-dalek = { version = "2", features = ["pkcs8", "pem", "rand_core"] }
|
||||
# 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"] }
|
||||
|
|
|
|||
|
|
@ -95,24 +95,31 @@ fn strip_prefix(key: &str) -> Result<&str, String> {
|
|||
Err("License key must start with SR-BASE- or SR-PREMIUM-".to_string())
|
||||
}
|
||||
|
||||
/// Pure validation: decode the JWT, verify signature against `public_key_pem`, 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, public_key_pem: &[u8]) -> Result<LicenseInfo, String> {
|
||||
let jwt = strip_prefix(key)?;
|
||||
|
||||
let decoding_key = DecodingKey::from_ed_pem(public_key_pem)
|
||||
.map_err(|e| format!("Invalid public key: {}", e))?;
|
||||
|
||||
/// 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);
|
||||
// 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"]);
|
||||
validation
|
||||
}
|
||||
|
||||
let data = decode::<LicenseClaims>(jwt, &decoding_key, &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;
|
||||
|
|
@ -135,17 +142,11 @@ fn validate_with_key(key: &str, public_key_pem: &[u8]) -> Result<LicenseInfo, St
|
|||
fn validate_activation_with_key(
|
||||
token: &str,
|
||||
local_machine_id: &str,
|
||||
public_key_pem: &[u8],
|
||||
decoding_key: &DecodingKey,
|
||||
) -> Result<(), String> {
|
||||
let decoding_key = DecodingKey::from_ed_pem(public_key_pem)
|
||||
.map_err(|e| format!("Invalid public key: {}", e))?;
|
||||
let validation = strict_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)
|
||||
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 {
|
||||
|
|
@ -160,14 +161,16 @@ fn validate_activation_with_key(
|
|||
/// before the user confirms storage.
|
||||
#[tauri::command]
|
||||
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
|
||||
validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes())
|
||||
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 info = validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes())?;
|
||||
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))?;
|
||||
|
|
@ -181,7 +184,8 @@ pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo,
|
|||
#[tauri::command]
|
||||
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
|
||||
let local_id = machine_id_internal()?;
|
||||
validate_activation_with_key(&token, &local_id, PUBLIC_KEY_PEM.as_bytes())?;
|
||||
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))?;
|
||||
|
|
@ -198,7 +202,10 @@ pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String
|
|||
return Ok(None);
|
||||
}
|
||||
let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?;
|
||||
Ok(validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes()).ok())
|
||||
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.
|
||||
|
|
@ -227,7 +234,10 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
|||
let Ok(key) = fs::read_to_string(&path) else {
|
||||
return EDITION_FREE.to_string();
|
||||
};
|
||||
let Ok(info) = validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes()) else {
|
||||
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();
|
||||
};
|
||||
|
||||
|
|
@ -241,8 +251,7 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
|||
let Ok(local_id) = machine_id_internal() else {
|
||||
return EDITION_FREE.to_string();
|
||||
};
|
||||
if validate_activation_with_key(&token, &local_id, PUBLIC_KEY_PEM.as_bytes()).is_err()
|
||||
{
|
||||
if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() {
|
||||
return EDITION_FREE.to_string();
|
||||
}
|
||||
}
|
||||
|
|
@ -267,31 +276,64 @@ fn machine_id_internal() -> Result<String, String> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
|
||||
// === Manual DER encoders for Ed25519 keys =================================================
|
||||
// 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: prefix + 32-byte raw key.
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Wrap a 32-byte Ed25519 public key in a SubjectPublicKeyInfo DER blob.
|
||||
fn ed25519_spki_public_der(pubkey: &[u8; 32]) -> Vec<u8> {
|
||||
// SEQUENCE(42) {
|
||||
// SEQUENCE(5) { OID(3) 1.3.101.112 }
|
||||
// BIT STRING(33) { 00 <32 bytes> }
|
||||
// }
|
||||
let mut der = vec![
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
];
|
||||
der.extend_from_slice(pubkey);
|
||||
der
|
||||
}
|
||||
|
||||
/// Build a deterministic test keypair so signed tokens are reproducible across runs.
|
||||
fn test_keypair() -> (String, String) {
|
||||
let seed = [42u8; 32];
|
||||
/// Returns (private_der, decoding_key_for_verification).
|
||||
fn test_keys(seed: [u8; 32]) -> (EncodingKey, DecodingKey) {
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let private_pem = signing_key
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let public_pem = verifying_key.to_public_key_pem(LineEnding::LF).unwrap();
|
||||
(private_pem, public_pem)
|
||||
let pubkey_bytes = signing_key.verifying_key().to_bytes();
|
||||
let priv_der = ed25519_pkcs8_private_der(&seed);
|
||||
let pub_der = ed25519_spki_public_der(&pubkey_bytes);
|
||||
let encoding_key = EncodingKey::from_ed_der(&priv_der);
|
||||
let decoding_key = DecodingKey::from_ed_der(&pub_der);
|
||||
(encoding_key, decoding_key)
|
||||
}
|
||||
|
||||
fn make_token(private_pem: &str, claims: &LicenseClaims) -> String {
|
||||
let key = EncodingKey::from_ed_pem(private_pem.as_bytes()).unwrap();
|
||||
encode(&Header::new(Algorithm::EdDSA), claims, &key).unwrap()
|
||||
fn default_keys() -> (EncodingKey, DecodingKey) {
|
||||
test_keys([42u8; 32])
|
||||
}
|
||||
|
||||
fn make_activation_token(private_pem: &str, claims: &ActivationClaims) -> String {
|
||||
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 {
|
||||
encode(&Header::new(Algorithm::EdDSA), claims, encoding_key).unwrap()
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
|
|
@ -303,14 +345,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn rejects_key_without_prefix() {
|
||||
let (_priv, public_pem) = test_keypair();
|
||||
let result = validate_with_key("nonsense", public_pem.as_bytes());
|
||||
let (_enc, dec) = default_keys();
|
||||
let result = validate_with_key("nonsense", &dec);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_well_formed_base_license() {
|
||||
let (private_pem, public_pem) = test_keypair();
|
||||
let (enc, dec) = default_keys();
|
||||
let claims = LicenseClaims {
|
||||
sub: "user@example.com".to_string(),
|
||||
iss: "lacompagniemaximus.com".to_string(),
|
||||
|
|
@ -320,9 +362,9 @@ mod tests {
|
|||
features: vec!["auto-update".to_string()],
|
||||
machine_limit: 3,
|
||||
};
|
||||
let jwt = make_token(&private_pem, &claims);
|
||||
let jwt = make_token(&enc, &claims);
|
||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||
let info = validate_with_key(&key, public_pem.as_bytes()).unwrap();
|
||||
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);
|
||||
|
|
@ -330,7 +372,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn rejects_expired_license() {
|
||||
let (private_pem, public_pem) = test_keypair();
|
||||
let (enc, dec) = default_keys();
|
||||
let claims = LicenseClaims {
|
||||
sub: "user@example.com".to_string(),
|
||||
iss: "lacompagniemaximus.com".to_string(),
|
||||
|
|
@ -340,22 +382,16 @@ mod tests {
|
|||
features: vec![],
|
||||
machine_limit: 3,
|
||||
};
|
||||
let jwt = make_token(&private_pem, &claims);
|
||||
let jwt = make_token(&enc, &claims);
|
||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||
let result = validate_with_key(&key, public_pem.as_bytes());
|
||||
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 (private_pem, _public_pem) = test_keypair();
|
||||
// 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 (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(),
|
||||
|
|
@ -365,23 +401,23 @@ mod tests {
|
|||
features: vec![],
|
||||
machine_limit: 3,
|
||||
};
|
||||
let jwt = make_token(&private_pem, &claims);
|
||||
let jwt = make_token(&enc_signer, &claims);
|
||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||
let result = validate_with_key(&key, other_public.as_bytes());
|
||||
let result = validate_with_key(&key, &dec_other);
|
||||
assert!(result.is_err(), "wrong-key signature must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corrupted_jwt() {
|
||||
let (_priv, public_pem) = test_keypair();
|
||||
let (_enc, dec) = default_keys();
|
||||
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
|
||||
let result = validate_with_key(&key, public_pem.as_bytes());
|
||||
let result = validate_with_key(&key, &dec);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_edition() {
|
||||
let (private_pem, public_pem) = test_keypair();
|
||||
let (enc, dec) = default_keys();
|
||||
let claims = LicenseClaims {
|
||||
sub: "user@example.com".to_string(),
|
||||
iss: "lacompagniemaximus.com".to_string(),
|
||||
|
|
@ -391,36 +427,42 @@ mod tests {
|
|||
features: vec![],
|
||||
machine_limit: 3,
|
||||
};
|
||||
let jwt = make_token(&private_pem, &claims);
|
||||
let jwt = make_token(&enc, &claims);
|
||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||
let result = validate_with_key(&key, public_pem.as_bytes());
|
||||
let result = validate_with_key(&key, &dec);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_token_matches_machine() {
|
||||
let (private_pem, public_pem) = test_keypair();
|
||||
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_activation_token(&private_pem, &claims);
|
||||
assert!(validate_activation_with_key(&token, "this-machine", public_pem.as_bytes()).is_ok());
|
||||
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 (private_pem, public_pem) = test_keypair();
|
||||
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_activation_token(&private_pem, &claims);
|
||||
let result = validate_activation_with_key(&token, "machine-B", public_pem.as_bytes());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue