From d3ded041ff73d49e66b4d4c3013f651662d23ab2 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 13 Sep 2026 03:51:50 -0400 Subject: [PATCH 1/3] docs(license): correct offline grace ladder to match code P6-E12-W4-S4-T2 unified FailOpenHardTTL from a separate 14-day value to GraceHardThreshold (7 days), but the offline-mode docs and the simulate-offline help text were never updated. They still describe a ladder the CLI has not implemented since that change. The wiki page was wrong in three separate ways, not one: - warning starts at 72h, not 7 days (GraceSoftThreshold) - the ceiling is 7 days, not 14 (GraceHardThreshold) - past the ceiling the CLI goes READ-ONLY (CanProceed true, WriteAllowed false), it does not refuse to run It also documented three environment variables that do not exist in the codebase (NSELF_LICENSE_OFFLINE_MAX_DAYS, NSELF_LICENSE_CACHE_PATH, NSELF_LICENSE_OFFLINE) and an air-gap flow built on two of them. The real cache override is LICENSE_CACHE_PATH at ~/.cache/nself/license.json, and the real air-gap path is license export/import. Rewrites the page against the code, separates the two clocks that were conflated (cache age vs server-reported expiry, the latter carrying its own 30-day PostExpiryGraceWindow), and records why the 7-day ceiling deliberately does not widen. Also corrects simulate-offline's examples, which used 7 and 14 as the warning and hard-stop days when those now land in the silent and read-only bands respectively. No threshold is changed; this is documentation catching up to code. --- .github/wiki/license-verification.md | 77 ++++++++++++++++++++-------- cmd/commands/license_simulate.go | 9 ++-- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/.github/wiki/license-verification.md b/.github/wiki/license-verification.md index d9ef2de17..3d295d67a 100644 --- a/.github/wiki/license-verification.md +++ b/.github/wiki/license-verification.md @@ -4,23 +4,43 @@ ## Fail-Open Policy -The CLI prefers availability over strict revocation. The cache TTL ladder governs behavior when remote validation is unreachable. +The CLI prefers availability over strict revocation. Two independent clocks govern behavior, and they are easy to confuse: -| Cache age | Behavior | User signal | -|---|---|---| -| 0 to 7 days | FAIL-OPEN, silent | None | -| 7 to 14 days | FAIL-OPEN, warning to stderr | `warning: license cache is N days old; refresh when online` | -| Over 14 days | Fail-closed, command refuses to run | `error: license cache expired; run 'nself license refresh'` | -| Bad signature, any age | Fail-closed, always | `error: license signature invalid` | +- **Cache age** — how long since the CLI last reached `ping.nself.org`. This is the offline ladder below. +- **License expiry** — the server-reported end of the subscription. See [Post-Expiry Grace](#post-expiry-grace). + +### Offline ladder (cache age) + +Thresholds are `GraceSoftThreshold` and `GraceHardThreshold` in `internal/license/grace.go`. + +| Cache age | State | Behavior | User signal | +|---|---|---|---| +| Under 72 hours | `valid` | Fail-open, silent | None | +| 72 hours to 7 days | `grace_soft` | Fail-open, full access, warning | `License validation is N old. Connect to the internet to refresh.` plus the remaining window | +| Over 7 days | `grace_hard` | **Read-only** — commands still run, writes are refused | `License validation expired (N offline). Paid plugins are in read-only mode.` | +| Bad signature, any age | — | Fail-closed, always | Signature verification failure | + +Two properties of this table are load-bearing and are the ones most often misremembered: + +**72 hours, not 7 days, is when the warning starts.** The silent window is deliberately sized to cover a Friday-evening-to-Monday-morning outage *on our side* with margin, so a blip on the license server never alarms a paying customer mid-weekend. -A bad signature is never accepted. Cache age cannot bypass cryptographic verification. +**Past 7 days the CLI degrades to read-only — it does not refuse to run.** `CanProceed` stays true and only `WriteAllowed` flips to false. You keep your stack readable and inspectable while offline; you cannot mutate it until you refresh. + +The 7-day ceiling does **not** widen alongside the soft threshold. Validation sends only the license key over the wire, with no per-machine identifier, so a local cache is a bare copyable credential. Every extra day of ceiling multiplies that exposure, and 7 days is the accepted tradeoff between outage tolerance and copied-cache abuse. + +A bad signature is never accepted. Cache age cannot bypass cryptographic verification — the cache carries an Ed25519 signature that is checked locally on every command. + +### Post-expiry grace + +Separately from the offline ladder, a license whose server-reported expiry has passed keeps working for **30 days** (`PostExpiryGraceWindow`), with a warning, before paid plugins go dormant. Reaching this state means the subscription lapsed, not that the network is down; refreshing the cache will not clear it. ## Configuration | Variable | Default | Purpose | |---|---|---| -| `NSELF_LICENSE_OFFLINE_MAX_DAYS` | `14` | Hard cap before fail-closed. Set lower for stricter posture. | -| `NSELF_LICENSE_CACHE_PATH` | `~/.nself/license/cache.json` | Cache file location. Override for shared CI runners or air-gapped hosts. | +| `LICENSE_CACHE_PATH` | `~/.cache/nself/license.json` | Cache file location. Override for shared CI runners or air-gapped hosts. | + +The offline thresholds are compile-time constants, not configuration. There is no environment variable that widens or narrows the grace window — tightening posture is done by refreshing more often, not by lowering a cap. ## Manual Refresh @@ -28,32 +48,49 @@ A bad signature is never accepted. Cache age cannot bypass cryptographic verific nself license refresh ``` -Pulls a fresh signed artifact from `ping.nself.org/license/validate`, replaces the cache, resets the age clock. Run after extended offline periods, after key rotation, or when the warning fires. +Pulls a fresh signed artifact from `ping.nself.org/license/validate` for every configured key, replaces the cache, and resets the age clock. Run after extended offline periods, after key rotation, or when the warning fires. ## Air-Gapped Mode -For hosts that never reach the public internet: +For hosts that never reach the public internet, move a signed cache across the air gap with `export` / `import`. Run the export on a machine that *can* reach `ping.nself.org` and holds the same license key: -1. Request an offline license key from `cloud.nself.org/account/offline-license` -2. Save the signed `.nself-offline-license` file -3. Place the file at `~/.nself/license/offline.key` -4. Set `NSELF_LICENSE_OFFLINE=1` +```bash +# On the connected machine +nself license refresh +nself license export > license-cache.json + +# Transfer the file, then on the air-gapped host +nself license import license-cache.json +``` + +The imported entry keeps its Ed25519 signature and is verified locally, so an air-gapped host gets the same guarantees as a connected one. The transferred cache ages on the same ladder above, so repeat the transfer before the 7-day ceiling to stay out of read-only mode. -The CLI validates the signature locally and skips remote checks. Offline keys carry an embedded expiration (typically 1 year). Renew before expiration through the same workflow. +`NSELF_LICENSE_SKIP_VERIFY=1` exists for importing an unsigned entry and requires `--force` to be acknowledged explicitly. It bypasses signature verification — do not use it outside local testing. + +## Verifying the ladder + +`simulate-offline` backdates the cache so you can exercise each band without waiting. It requires `LICENSE_ALLOW_SIMULATION=true` and is disabled by default: + +```bash +LICENSE_ALLOW_SIMULATION=true nself license simulate-offline 1 # silent +LICENSE_ALLOW_SIMULATION=true nself license simulate-offline 5 # warning +LICENSE_ALLOW_SIMULATION=true nself license simulate-offline 10 # read-only +nself license simulate-offline --clear # reset +``` ## Troubleshooting -**Cache corruption.** Delete `~/.nself/license/cache.json` and run `nself license refresh`. The cache is regenerable. +**Cache corruption.** Delete `~/.cache/nself/license.json` and run `nself license refresh`. The cache is regenerable. **Signature invalid after CLI upgrade.** Run `nself license refresh`. Major version bumps may rotate signing keys. -**Manual signature verification.** There is no separate verify subcommand, every command validates the cached signature locally before it runs (see above). To inspect the cache contents without triggering a network call, print it directly: +**Manual signature verification.** There is no separate verify subcommand — every command validates the cached signature locally before it runs. To inspect the cache contents without triggering a network call, print it directly: ```bash nself license show --json ``` -**Stuck in fail-closed.** Connect to the internet and run `nself license refresh`. If the network is restricted, request an offline license key. +**Stuck in read-only.** The cache is more than 7 days old. Connect to the internet and run `nself license refresh`. If the network is restricted, use the air-gap `export` / `import` flow above. ## See Also diff --git a/cmd/commands/license_simulate.go b/cmd/commands/license_simulate.go index e0b2ac296..54ed9d854 100644 --- a/cmd/commands/license_simulate.go +++ b/cmd/commands/license_simulate.go @@ -18,10 +18,13 @@ Requires LICENSE_ALLOW_SIMULATION=true (disabled by default in production). Backdates the license cache to appear as if the system has been offline for the specified number of days. +The grace ladder is keyed on GraceSoftThreshold (72h) and GraceHardThreshold +(7 days), so the days below are chosen to land inside each band. + Examples: - nself license simulate-offline 0 # Just went offline - nself license simulate-offline 7 # Trigger warning banner - nself license simulate-offline 14 # Trigger hard stop + nself license simulate-offline 0 # Just went offline — silent + nself license simulate-offline 5 # Past 72h — warning, still writable + nself license simulate-offline 10 # Past 7 days — read-only nself license simulate-offline --clear # Reset to current time`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { From e3394d89baed09aa4e5ee23ec268b48e61b382f7 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 13 Sep 2026 03:52:15 -0400 Subject: [PATCH 2/3] docs(license): note that installs fail closed where commands go read-only bundleEntitledFromGrace requires WriteAllowed, so a bundle install past the 7-day ceiling is refused rather than degraded. Without this the wiki page and web's licensing/offline page read as contradicting each other on the same threshold. --- .github/wiki/license-verification.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/wiki/license-verification.md b/.github/wiki/license-verification.md index 3d295d67a..7e3528fcd 100644 --- a/.github/wiki/license-verification.md +++ b/.github/wiki/license-verification.md @@ -26,6 +26,8 @@ Two properties of this table are load-bearing and are the ones most often misrem **Past 7 days the CLI degrades to read-only — it does not refuse to run.** `CanProceed` stays true and only `WriteAllowed` flips to false. You keep your stack readable and inspectable while offline; you cannot mutate it until you refresh. +**Plugin installs are stricter than ordinary commands.** `bundleEntitledFromGrace` requires `WriteAllowed`, so once the cache passes 7 days a bundle install is *refused outright* rather than degraded — installing is a write. This is why the [offline licensing page](https://nself.org/docs/licensing/offline) describes the ceiling as failing closed while this page describes it as read-only: same threshold, two different call paths. A revoked license is refused at any cache age on both paths. + The 7-day ceiling does **not** widen alongside the soft threshold. Validation sends only the license key over the wire, with no per-machine identifier, so a local cache is a bare copyable credential. Every extra day of ceiling multiplies that exposure, and 7 days is the accepted tradeoff between outage tolerance and copied-cache abuse. A bad signature is never accepted. Cache age cannot bypass cryptographic verification — the cache carries an Ed25519 signature that is checked locally on every command. From 00676f6cfd0fcc289de0cfcb4f12582737e69aaf Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 13 Sep 2026 03:52:34 -0400 Subject: [PATCH 3/3] docs(license): document NSELF_LICENSE_FAIL_OPEN The rewrite asserted no env var widens the grace window. That is wrong: NSELF_LICENSE_FAIL_OPEN=1 takes the unbounded bundleEntitledFromCache branch (checker.go:84-87), removing the age ceiling entirely for CI and air-gap installs. Documents it with the production warning, and states precisely that it switches branch rather than retuning the thresholds. --- .github/wiki/license-verification.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/wiki/license-verification.md b/.github/wiki/license-verification.md index 7e3528fcd..9bdab27ae 100644 --- a/.github/wiki/license-verification.md +++ b/.github/wiki/license-verification.md @@ -41,8 +41,9 @@ Separately from the offline ladder, a license whose server-reported expiry has p | Variable | Default | Purpose | |---|---|---| | `LICENSE_CACHE_PATH` | `~/.cache/nself/license.json` | Cache file location. Override for shared CI runners or air-gapped hosts. | +| `NSELF_LICENSE_FAIL_OPEN` | unset | `=1` replaces the bounded ladder with an **unbounded** cache check on the network-unreachable branch (`checker.go:84-87`). CI and air-gap only. | -The offline thresholds are compile-time constants, not configuration. There is no environment variable that widens or narrows the grace window — tightening posture is done by refreshing more often, not by lowering a cap. +The 72h and 7d thresholds themselves are compile-time constants, not configuration — nothing tunes them to a different number. `NSELF_LICENSE_FAIL_OPEN=1` does not move them either; it takes a different branch entirely (`bundleEntitledFromCache`, tier check with no time window at all), which is why it must never be set on a production install. It does not disable revocation or override a server that answers 401/403. ## Manual Refresh