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
80 changes: 60 additions & 20 deletions .github/wiki/license-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,56 +4,96 @@

## 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

```bash
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 subcommandevery 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

Expand Down
9 changes: 6 additions & 3 deletions cmd/commands/license_simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading