# Spec 1 — Licensing Core

Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a (`shared-module`), spec 0b (`instance-rename`)
Ships: independently. Adds a package and a CLI; changes no running behaviour.

## Context

Licenses are **offline-verified signed blobs**. A Vantage server checks a
signature and an expiry date and asks nobody's permission. That choice buys
self-hosted installs that work in air-gapped networks and a control plane with no
licensing availability dependency.

It costs revocation. Once issued, a license is valid until it expires, whatever
Paddle later says. Every other decision in the programme follows from accepting
that: Self Hosted is annual-only so the unenforceable window is bounded, and
cancellation takes effect at term end rather than immediately (spec 5).

This spec defines the payload, the signing and verification, and a CLI to issue
licenses by hand. It deliberately lands before the admin site so that specs 1+2
together give working licensing with no new service to operate.

## Goals

1. One struct, in `shared`, read identically by the verifier and the issuer.
2. Verification that needs no network, no clock sync beyond a rough one, and no
   configuration.
3. A hand-issuance path good enough to run production on until spec 3 lands.

## Non-goals

- Storing licenses. Spec 2 owns the instance document; spec 3 owns issuance
  history.
- Deciding tier contents. Tiers are data; the values in this spec are the
  initial seed, and spec 3's `plans` table becomes their home.
- Any phone-home, revocation list or online check. There is none, anywhere, by
  design.

## Design

### Package

`shared/license/`, inside the module created by spec 0a:

```
shared/license/
├── license.go     # License, Limits, feature constants
├── sign.go        # Sign, build-tagged out of the server binary
├── verify.go      # Verify, Parse
├── keys.go        # trustedPublicKeys
└── license_test.go
```

Uses `github.com/hyperboloide/lk` (ECDSA P-384 with SHA-256, base32 encoding).

### Payload

```go
package license

type License struct {
    ID           string    `json:"id"`            // uuid, for support and audit
    InstanceID   string    `json:"instance_id"`   // the instance this license is bound to
    AccountID    string    `json:"account_id"`    // admin-side customer, informational
    InstanceName string    `json:"instance_name"` // display only
    Tier         string    `json:"tier"`          // "free" | "professional" | "self_hosted"
    Deployment   string    `json:"deployment"`    // "cloud" | "self_hosted"
    IssuedAt     time.Time `json:"issued_at"`
    ExpiresAt    time.Time `json:"expires_at"`
    Limits       Limits    `json:"limits"`
    Features     []string  `json:"features"`
}

type Limits struct {
    MaxServers      int `json:"max_servers"`       // -1 means unlimited
    MaxSecretGroups int `json:"max_secret_groups"`
    MaxChannels     int `json:"max_channels"`
}

const (
    FeatureConsole = "console" // browser SSH/RDP/VNC
    FeatureOIDC    = "oidc"    // per-instance single sign-on
)

const (
    TierFree         = "free"
    TierProfessional = "professional"
    TierSelfHosted   = "self_hosted"

    DeploymentCloud      = "cloud"
    DeploymentSelfHosted = "self_hosted"
)
```

`InstanceID` is **always populated**. There is no unbound license: the
self-hosted purchase flow (spec 4) links the instance UUID before the license is
issued, so binding happens at signing time. This removes the claim endpoint, the
best-effort phone-home and the multi-claim reconciliation that an unbound design
would have needed.

**The server never branches on `Tier`.** It reads `Limits` and `Features` only.
`Tier` exists for display, support and analytics. Adding a tier, or changing what
a tier includes, must never require a server release.

### Tier seed values

Recorded here as the initial contents of spec 3's `plans` table. Snapshotted into
each license at issue, so changing the table never rewrites an issued license —
the same principle as `workflow_runs.steps_snapshot`.

| | Free | Professional | Self Hosted |
|---|---|---|---|
| `deployment` | `cloud` | `cloud` | `self_hosted` |
| `max_servers` | 3 | -1 | -1 |
| `max_secret_groups` | 1 | -1 | -1 |
| `max_channels` | 1 | -1 | -1 |
| `console` | no | yes | yes |
| `oidc` | no | yes | yes |
| billing term | monthly, £0 | monthly or annual | **annual only** |

Free is cloud-only. A self-hosted install can never hold a valid Free license
because Free is only ever signed with `deployment: "cloud"`, and verification
rejects a deployment mismatch. There is no server-side flag to edit.

### Signing

```go
//go:build !noSign

func Sign(l License, privateKeyHex string) (string, error)
```

Marshals to canonical JSON, signs with lk, returns the base32 blob.

`Sign` is excluded from the server binary with a build tag. The server has no
reason to hold signing code and there is no reason to ship it into a customer's
data centre.

The private key lives in `LICENSE_SIGNING_KEY` on the issuing side only — the
CLI now, the admin backend from spec 3. It is never in the repo, never in an
image, never in the control plane's environment.

### Verification

```go
type VerifyOpts struct {
    InstanceID string    // required: the verifier's own instance
    Deployment string    // required: "cloud" or "self_hosted"
    Now        time.Time // injectable for tests
}

type Result struct {
    License License
    State   State // Valid, Expired, Invalid
    Reason  string
}

const (
    StateValid   State = "valid"
    StateExpired State = "expired"
    StateInvalid State = "invalid"
)

func Verify(blob string, opts VerifyOpts) Result
```

Checks, in order, stopping at the first failure:

1. Blob decodes and the signature verifies against one of `trustedPublicKeys`.
   Failure → `Invalid`, reason `bad_signature`.
2. `l.Deployment == opts.Deployment`. Failure → `Invalid`, reason
   `deployment_mismatch`. This is the check that makes Free cloud-only.
3. `l.InstanceID == opts.InstanceID`. Failure → `Invalid`, reason
   `instance_mismatch`.
4. `opts.Now.Before(l.ExpiresAt)`. Failure → `Expired`.
5. Otherwise `Valid`.

**`Expired` and `Invalid` are distinct states and the caller treats them
differently in messaging** (spec 2), even though both degrade the instance the
same way. A customer whose card failed and a customer who pasted the wrong blob
need different words.

`Parse(blob) (License, error)` verifies the signature only, ignoring binding and
expiry. Used by the admin site to display a license and by support to inspect a
blob a customer has emailed in. Never used for enforcement.

Clock skew: no tolerance is applied. Terms are a month or a year; a server whose
clock is wrong by enough to matter has bigger problems, and a tolerance window is
a thing to get wrong. `Verify` logs at warn level if `IssuedAt` is in the future,
which is the signal that a clock is badly off.

### Key management

```go
// trustedPublicKeys is ordered. Index 0 is the current signing key.
// To rotate: prepend the new key, ship a server release, then reissue.
// Remove a retired key only after every license signed with it has expired.
var trustedPublicKeys = []string{
    "<base32 ECDSA P-384 public key>",
}
```

A slice from day one even though it holds one entry, because retrofitting a
single-key verifier into a multi-key one during an incident is not a thing to
plan for.

Public keys are compiled in. They are not configurable, because a configurable
trust root is a licensing bypass: a self-hosted operator could point it at a
keypair they generated.

Key generation is a documented one-off:

```
go run ./shared/license/cmd/lkgen keypair
```

prints a private key (base32) for the vault and a public key (base32) to paste into
`keys.go`. The private key is stored in a password manager and in the admin
service's environment. **If it is lost, no new licenses can be issued for any
existing customer without a server release.** Back it up in two places.

### CLI issuer

`shared/license/cmd/lkctl`, built only for internal use:

```
lkctl keypair
lkctl issue --instance-id=<uuid> --instance-name="Acme" \
            --tier=professional --deployment=cloud \
            --term=1y [--account-id=<id>] [--out=acme.lic]
lkctl inspect <file-or-blob>
```

`issue` reads `LICENSE_SIGNING_KEY`, applies the tier seed values from a table
compiled into the CLI, and prints the blob. `--term` accepts `1m`, `1y` or an
explicit `--expires=RFC3339`.

This is the production issuance path until spec 3 ships. It is kept afterwards
for support and disaster recovery — if the admin service is down and a customer's
license expires, a blob can still be cut by hand.

Issued blobs from `lkctl` are not recorded anywhere. Spec 3 backfills its
`licenses` table from `inspect` output when it takes over.

## Testing

`shared/license` is pure and needs no database, so this suite is fast and
thorough. Written test-first.

1. Round trip: `Sign` then `Verify` returns `Valid` with an identical payload.
2. Tampering: flip one character of the blob → `Invalid`, `bad_signature`.
3. Tampering with intent: re-sign a payload with a *different* keypair →
   `Invalid`. This is the test that proves an attacker cannot mint licenses.
4. Expiry: `ExpiresAt` one second in the past → `Expired`. One second in the
   future → `Valid`.
5. Deployment mismatch: a Free (`cloud`) license verified with
   `Deployment: "self_hosted"` → `Invalid`, `deployment_mismatch`.
6. Instance mismatch: correct signature, different `InstanceID` → `Invalid`,
   `instance_mismatch`.
7. Check order: a blob that is both expired *and* instance-mismatched reports
   `instance_mismatch`, not `Expired`. Order is part of the contract because the
   reason drives the message.
8. Multi-key: a license signed with `trustedPublicKeys[1]` verifies. One signed
   with a key not in the slice does not.
9. `Parse` returns the payload for an expired and for a mismatched license, and
   errors for a bad signature.
10. Unicode and long instance names survive the round trip.
11. Golden blob: a fixture blob checked into the repo, signed with a **test-only**
    keypair, must keep verifying. This catches an accidental change to the
    canonical JSON encoding, which would silently invalidate every issued
    license in the field.

Test 11 matters more than it looks. The encoding is part of the wire format.

## Verification before merge

1. `go test ./shared/license/...` passes, including the golden fixture.
2. `lkctl keypair` → `lkctl issue` → `lkctl inspect` round trips at the command
   line.
3. `go build -tags noSign ./server/...` succeeds and
   `go tool nm` on the resulting binary shows no `license.Sign` symbol.
4. The production keypair is generated, the private half stored in two places,
   and the public half committed in `keys.go`.

## Risks

| Risk | Mitigation |
|---|---|
| Signing key lost | Documented two-location backup; generation is a one-off with an explicit checklist |
| Signing key leaked | Rotation path exists from day one: prepend key, release, reissue. Retire the old key once its licenses expire |
| Canonical encoding changes | Golden fixture test |
| Signing code shipped to customers | Build tag plus a symbol check in verification |
| No revocation | Accepted and documented. Bounded by term length; Self Hosted is annual-only |
