diff --git a/.github/wiki/license-verification.md b/.github/wiki/license-verification.md index d9ef2de1..9bdab27a 100644 --- a/.github/wiki/license-verification.md +++ b/.github/wiki/license-verification.md @@ -4,23 +4,46 @@ ## 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. + +**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. + +### Post-expiry grace -A bad signature is never accepted. Cache age cannot bypass cryptographic verification. +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. | +| `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 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 @@ -28,32 +51,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: + +```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 +``` -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` +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 e0b2ac29..54ed9d85 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 {