diff --git a/site/src/content/docs/licence.md b/site/src/content/docs/licence.md index b536ed55..d512faa7 100644 --- a/site/src/content/docs/licence.md +++ b/site/src/content/docs/licence.md @@ -35,6 +35,11 @@ check allows up to 30 days offline. The Licence page shows the last successful check and days remaining. When 7 days or fewer remain, the app shows a reminder, including at 3 days and 1 day. Choose **Check licence now** after reconnecting. A failed check leaves the existing allowance intact; it does not add extra days. +If the app says it could not verify the request, check that you used the complete +purchase key and that your computer's date and time are correct. Try again, or +contact info@theswitchify.app if the message continues. This message does not +revoke an existing allowance. If the app instead asks you to connect, check your +internet connection before retrying. After restarting the app, Switchify PC checks the saved licence against this computer's protected installation credentials. If your credential store is diff --git a/src-tauri/src/licence_activation.rs b/src-tauri/src/licence_activation.rs index a19b533c..6b6d0e52 100644 --- a/src-tauri/src/licence_activation.rs +++ b/src-tauri/src/licence_activation.rs @@ -8,6 +8,7 @@ use std::{future::Future, pin::Pin, time::Duration}; const SERVICE: &str = "com.enaboapps.switchify.pc.licence"; pub const NETWORK_ERROR: &str = "Could not check your licence. Connect to the internet and try again. Your existing offline allowance is unchanged."; +pub const INVALID_REQUEST_ERROR: &str = "Could not verify this licence request. Check your key and your computer's date and time, or contact support. Your existing offline allowance is unchanged."; pub const STORAGE_ERROR: &str = "Your installation key is unavailable. Unlock your keychain and try again. Your existing offline allowance is unchanged."; #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -169,6 +170,24 @@ pub trait Transport: Send + Sync { fn post(&self, config: &Config, body: Value) -> ResponseFuture; } pub struct HttpTransport; +fn decode_response(status: reqwest::StatusCode, bytes: &[u8]) -> Result { + let value: Value = serde_json::from_slice(bytes).map_err(|_| NETWORK_ERROR)?; + if status == reqwest::StatusCode::BAD_REQUEST { + // A narrowly classified unsigned error gives guidance, never authority + // to change an entitlement or accept a proof in an error response. + return if value.as_object().is_some_and(|object| { + object.len() == 1 && object.get("outcome").is_some_and(|v| v == "invalid") + }) { + Ok(value) + } else { + Err(NETWORK_ERROR.into()) + }; + } + if !status.is_success() && status.as_u16() != 409 && status.as_u16() != 429 { + return Err(NETWORK_ERROR.into()); + } + Ok(value) +} impl Transport for HttpTransport { fn post(&self, config: &Config, body: Value) -> ResponseFuture { let config = config.clone(); @@ -193,10 +212,7 @@ impl Transport for HttpTransport { } bytes.extend_from_slice(&chunk); } - if !status.is_success() && status.as_u16() != 409 && status.as_u16() != 429 { - return Err(NETWORK_ERROR.into()); - } - serde_json::from_slice(&bytes).map_err(|_| NETWORK_ERROR.into()) + decode_response(status, &bytes) }) } } @@ -216,6 +232,41 @@ pub fn request( #[cfg(test)] pub mod tests { use super::*; + #[test] + fn only_the_exact_bad_request_outcome_gets_invalid_request_guidance() { + use reqwest::StatusCode; + let value = decode_response(StatusCode::BAD_REQUEST, br#"{"outcome":"invalid"}"#).unwrap(); + assert_eq!(value["outcome"], "invalid"); + for body in [ + b"not json".as_slice(), + br#"{"outcome":"invalid","proof":"untrusted"}"#, + br#"{"proof":"untrusted"}"#, + br#"{"outcome":"revoked"}"#, + br#"{"outcome":"unknown"}"#, + ] { + assert!(decode_response(StatusCode::BAD_REQUEST, body).is_err()); + } + for status in [ + StatusCode::UNAUTHORIZED, + StatusCode::NOT_FOUND, + StatusCode::INTERNAL_SERVER_ERROR, + ] { + assert!(decode_response(status, br#"{"outcome":"invalid"}"#).is_err()); + } + assert_eq!( + decode_response(StatusCode::CONFLICT, br#"{"outcome":"seat_taken"}"#).unwrap() + ["outcome"], + "seat_taken" + ); + assert_eq!( + decode_response( + StatusCode::TOO_MANY_REQUESTS, + br#"{"outcome":"rate_limited"}"# + ) + .unwrap()["outcome"], + "rate_limited" + ); + } pub struct FakeIdentity; pub struct OtherIdentity; impl IdentityStore for OtherIdentity { diff --git a/src-tauri/src/licensing.rs b/src-tauri/src/licensing.rs index 55ffde51..2584a4f6 100644 --- a/src-tauri/src/licensing.rs +++ b/src-tauri/src/licensing.rs @@ -630,6 +630,9 @@ impl Licensing { ); let response = self.transport.post(config, body).await?; if let Some(outcome) = response.get("outcome").and_then(|v| v.as_str()) { + if outcome == "invalid" { + return Err(activation::INVALID_REQUEST_ERROR.into()); + } if matches!(outcome, "seat_taken" | "takeover_limited") { { let mut data = self.data.lock().unwrap_or_else(|p| p.into_inner()); @@ -1192,7 +1195,7 @@ mod tests { if outcome == "outage" { return Err(activation::NETWORK_ERROR.into()); } - if matches!(outcome, "seat_taken" | "takeover_limited") { + if matches!(outcome, "seat_taken" | "takeover_limited" | "invalid") { return Ok(json!({"outcome":outcome})); } let request = &body["request"]; @@ -1266,6 +1269,46 @@ mod tests { service.transport = transport.clone(); (service, transport, dir) } + #[tokio::test] + async fn unverifiable_request_guidance_does_not_replace_or_revoke_cached_access() { + let (service, transport, dir) = online_service(); + service + .check("activate", Some(&purchase()), false) + .await + .unwrap(); + let before = service.data.lock().unwrap().record.clone(); + *transport.outcome.lock().unwrap() = "invalid"; + assert_eq!( + service.check("check", None, false).await.unwrap_err(), + activation::INVALID_REQUEST_ERROR + ); + assert_eq!(service.view().status, "licensed"); + assert!(service.data.lock().unwrap().record == before); + std::fs::remove_dir_all(dir).unwrap(); + } + #[tokio::test] + async fn rejected_initial_activation_preserves_trial_and_suggests_key_and_clock_checks() { + let (service, transport, dir) = online_service(); + *transport.outcome.lock().unwrap() = "invalid"; + assert_eq!( + service + .check("activate", Some(&purchase()), false) + .await + .unwrap_err(), + activation::INVALID_REQUEST_ERROR + ); + assert_eq!(service.view().status, "trial"); + assert!(service + .data + .lock() + .unwrap() + .record + .as_ref() + .unwrap() + .key + .is_none()); + std::fs::remove_dir_all(dir).ok(); + } fn restart(source: &Licensing, record: Record) -> Licensing { let mut service = configured_service(record); service.keys = source.keys.clone();