Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/core-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ much offline tolerance is allowed:
API failures (network down, 5xx, etc.) fall back to the local result. Beyond
grace, the failure is propagated.

Both are measured from `validated_at` by the device's clock. A `validated_at` more
than a few minutes ahead of that clock says nothing about how long ago it was, so it
counts as past both: the API is called, and a failure to reach it is propagated.

Definitive server rejections (`license_invalid_error`, `license_expired_error`)
always propagate regardless of grace. See [Errors](#errors) for what counts as
definitive.
Expand Down
3 changes: 2 additions & 1 deletion docs/juce-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,8 @@ whenever you call `refreshLicense()`, throttled to no more than once per
`onlineCheckInterval`. A license stays usable offline until `onlineGracePeriod` elapses
since its last successful online validation; the controller's local check (see
[Expiry and other instances](#expiry-and-other-instances)) enforces that mid-session too,
with one last online attempt before it locks.
with one last online attempt before it locks. Both are measured by the computer's clock;
a last validation more than a few minutes ahead of it counts as past both.

## Add-ons

Expand Down
23 changes: 22 additions & 1 deletion include/moonbase/licensing.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@

namespace moonbase {

namespace detail {

// How long ago Moonbase last validated the license, by this device's clock: what
// online_validation_min_interval and online_validation_grace_period are measured
// against. Moonbase stamps the time with its own clock, so a device clock a little
// behind it reads a fresh validation as slightly in the future, which counts as
// just now. Further ahead than that, the device clock is wrong (set back since,
// say), and how long ago it was can't be known: that reads as the longest time
// there is, so Moonbase is asked, and the grace period is over if it can't be
// reached.
inline std::chrono::system_clock::duration time_since_validation(
const license& value,
std::chrono::system_clock::time_point now = std::chrono::system_clock::now())
{
constexpr auto clock_skew = std::chrono::minutes(5);
const auto elapsed = now - value.validated_at;
return elapsed < -clock_skew ? (std::chrono::system_clock::duration::max)() : elapsed;
}

} // namespace detail

class licensing {
public:
explicit licensing(
Expand Down Expand Up @@ -213,7 +234,7 @@ class licensing {
// fall back to the caller-supplied token.
}

const auto age = std::chrono::system_clock::now() - freshest.validated_at;
const auto age = detail::time_since_validation(freshest);

// The throttle is only allowed to skip the API while we're also
// inside the grace period — otherwise a min_interval longer than the
Expand Down
6 changes: 3 additions & 3 deletions modules/moonbase_licensing/juce/ActivationController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ void ActivationController::refreshLicense(bool force, std::function<void(bool)>
// period that is a network blip; past it the license has gone
// unverified for longer than the app allows, which locks, as it
// does at launch.
pastGrace = std::chrono::system_clock::now() - currentLicense.validated_at > gracePeriod;
pastGrace = moonbase::detail::time_since_validation(currentLicense) > gracePeriod;
}

juce::MessageManager::callAsync([safe, generation, refreshed, notCovered, expired, rejected, revoked,
Expand Down Expand Up @@ -1327,7 +1327,7 @@ void ActivationController::checkLicenseFile()
// start() would only take it after an online check, so don't unlock on it
// here either. Whoever re-validates it writes a fresh copy.
if (stored->method != moonbase::activation_method::offline
&& std::chrono::system_clock::now() - stored->validated_at > config_.onlineGracePeriod)
&& moonbase::detail::time_since_validation(*stored) > config_.onlineGracePeriod)
return;

if (notCovered)
Expand Down Expand Up @@ -1488,7 +1488,7 @@ void ActivationController::checkLicenseDeadlines()
const auto now = std::chrono::system_clock::now();
const bool expired = license_->expires_at && *license_->expires_at <= now;
const bool offline = license_->method == moonbase::activation_method::offline;
const bool pastGrace = ! offline && now - license_->validated_at > config_.onlineGracePeriod;
const bool pastGrace = ! offline && moonbase::detail::time_since_validation(*license_, now) > config_.onlineGracePeriod;
if (! expired && ! pastGrace)
return;

Expand Down
23 changes: 22 additions & 1 deletion modules/moonbase_licensing/moonbase/licensing.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@

namespace moonbase {

namespace detail {

// How long ago Moonbase last validated the license, by this device's clock: what
// online_validation_min_interval and online_validation_grace_period are measured
// against. Moonbase stamps the time with its own clock, so a device clock a little
// behind it reads a fresh validation as slightly in the future, which counts as
// just now. Further ahead than that, the device clock is wrong (set back since,
// say), and how long ago it was can't be known: that reads as the longest time
// there is, so Moonbase is asked, and the grace period is over if it can't be
// reached.
inline std::chrono::system_clock::duration time_since_validation(
const license& value,
std::chrono::system_clock::time_point now = std::chrono::system_clock::now())
{
constexpr auto clock_skew = std::chrono::minutes(5);
const auto elapsed = now - value.validated_at;
return elapsed < -clock_skew ? (std::chrono::system_clock::duration::max)() : elapsed;
}

} // namespace detail

class licensing {
public:
explicit licensing(
Expand Down Expand Up @@ -213,7 +234,7 @@ class licensing {
// fall back to the caller-supplied token.
}

const auto age = std::chrono::system_clock::now() - freshest.validated_at;
const auto age = detail::time_since_validation(freshest);

// The throttle is only allowed to skip the API while we're also
// inside the grace period — otherwise a min_interval longer than the
Expand Down
37 changes: 35 additions & 2 deletions tests/juce/controller_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,31 @@ TEST_CASE("a license offline past its grace period locks while the plugin is ope
CHECK(fx.licenseFile.existsAsFile());
}

TEST_CASE("a license validated ahead of this computer's clock locks while open once Moonbase can't be reached")
{
controller_fixture fx;
juce::StringArray diags;
fx.config.onDiagnostic = [&](const juce::String& m) { diags.add(m); };
// The clock is an hour behind the last validation: within the default 7-day
// grace period and the 5-minute throttle, if that counted as no time at all.
auto ahead = default_claims();
ahead["validated"] = now_seconds() + 60 * 60;
fx.seedStored(fx.token(ahead));
fx.transport->responses.push_back(moonbase::http_response{200, {}, fx.token(ahead)});

ActivationController controller(fx.config, fx.makeLicensing());
controller.start();
REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; }));
CHECK(fx.transport->requests.size() == 1); // start() asked Moonbase

// Nothing queued: the watch's re-check can't reach Moonbase.
CHECK(pumpUntil([&] { return controller.screen() == Screen::Welcome; }, 10000));
CHECK_FALSE(controller.licensedFlag().load());
CHECK(controller.lockReason() == LockReason::Unverified);
CHECK(fx.transport->requests.size() == 2);
CHECK(diags.joinIntoString(" ").contains("offline grace period"));
}

TEST_CASE("an activation in another instance unlocks this one without a reload")
{
controller_fixture fx;
Expand Down Expand Up @@ -1844,9 +1869,17 @@ TEST_CASE("a stored license past its grace period is not picked up without a ser
REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Welcome; }));

// Signed, for this device and unexpired, but last verified 8 days ago: past
// the default 7-day grace period.
// the default 7-day grace period. Or at a time this computer's clock hasn't
// reached, which says nothing about how long ago it was.
auto stale = default_claims();
stale["validated"] = now_seconds() - 8 * 24 * 3600;
SUBCASE("verified 8 days ago")
{
stale["validated"] = now_seconds() - 8 * 24 * 3600;
}
SUBCASE("verified ahead of the clock")
{
stale["validated"] = now_seconds() + 60 * 60;
}
fx.seedStored(fx.token(stale));

// Not even for a moment: two watch ticks, and it must never unlock.
Expand Down
42 changes: 42 additions & 0 deletions tests/licensing_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,48 @@ TEST_CASE("validate_token_online honors a custom min interval")
CHECK(fixture.transport->requests.size() == 1);
}

TEST_CASE("validate_token_online asks Moonbase when the last validation is ahead of the device clock")
{
licensing_options options;
options.online_validation_grace_period = std::chrono::hours(24 * 365);
facade_fixture fixture(options);

auto ahead = moonbase::tests::default_claims();
ahead["validated"] = moonbase::tests::now_seconds() + (60 * 60); // the clock is an hour behind it
const auto ahead_token = fixture.make_token(ahead);

SUBCASE("Moonbase answers")
{
auto refreshed = moonbase::tests::default_claims();
refreshed["validated"] = moonbase::tests::now_seconds();
const auto refreshed_token = fixture.make_token(refreshed);
fixture.transport->responses.push_back(http_response{200, {}, refreshed_token});

CHECK(fixture.instance.validate_token_online(ahead_token).token == refreshed_token);
CHECK(fixture.transport->requests.size() == 1);
}

SUBCASE("Moonbase can't be reached, and no grace period covers it")
{
// No queued response → recording_transport throws std::runtime_error.
CHECK_THROWS_AS(
(void)fixture.instance.validate_token_online(ahead_token),
std::exception);
CHECK(fixture.transport->requests.size() == 1);
}
}

TEST_CASE("validate_token_online throttles a validation only a little ahead of the device clock")
{
facade_fixture fixture;
auto claims = moonbase::tests::default_claims();
claims["validated"] = moonbase::tests::now_seconds() + 30; // Moonbase's clock runs a little ahead
const auto token = fixture.make_token(claims);

CHECK(fixture.instance.validate_token_online(token).token == token);
CHECK(fixture.transport->requests.empty());
}

TEST_CASE("revoke_activation refuses offline tokens without contacting the API")
{
facade_fixture fixture;
Expand Down
Loading