Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: wyrd
Copyright: 2026 Wyrd. All rights reserved.
License: 
# Wyrd licensing

Wyrd is distributed as a single binary. With no license installed it runs as a
free tier limited to **2 devices**. A paid deployment pastes a signed license
into the dashboard and gets the ceiling written into that license.

Verification is entirely offline — the server never phones home, so air-gapped
installs work normally.

## Operator view

| | Free | Licensed | Licensed, expired |
|---|---|---|---|
| Device ceiling | 2 | from the license (`null` = unlimited) | unchanged — keeps its paid ceiling |
| Issue commands | yes | yes | **no** |
| Store device-reported data | yes | yes | **no** |
| Device presence / `last_seen` | yes | yes | yes |
| Dashboard, history, existing data | yes | yes | yes |

## Installing a license

Click the licensee line at the bottom of the sidebar to open the **License**
page, paste the token, press *Add license*. It takes effect immediately — no
restart, and no file on disk. Tokens live in the `license_tokens` table, and any
admin can add one; the endpoint is deliberately *not* gated on the license being
valid, since that is the one action an expired server needs to be able to take.

The page also carries a buy/renew link and a support link, both stamped with
`client_id` and `license_id` so an operator arriving at either is already
identified:

```
https://my.wyrd.link/licenses?client_id=<uuid>&license_id=<uuid>
https://my.wyrd.link/support?client_id=<uuid>&license_id=<uuid>
```

Each is offered twice — the labelled button carries the address above, and the
narrow one beside it the same path on the portal's onion mirror, so an operator
running the dashboard behind Tor is never pushed onto the clearnet to renew.
Both halves carry the same two ids. The mirror comes from `src/links.rs` via
`GET /api/server-features`; a server too old to send that table renders the
single clearnet button it always did.

An expired license degrades the server rather than stopping it: devices stay
enrolled and connected, the dashboard keeps working, and everything resumes the
moment a renewal is pasted in. A yellow banner appears 30 days before
`valid_until`, counting down; the red banner and the degradation both start the
instant the date passes. There is no grace period.

A stored token that does not verify — a hand-edited row, or one signed by a key
you have since rotated — is logged, skipped, and listed on the license page as
*Invalid*. It can never stop the server from booting.

### Several licenses at once

Tokens are never replaced or deleted: a renewal is simply another row, and every
stored token is kept as history. Which one is in force is decided *at read time*,
so an expiring token hands over to its successor without a restart and without
anyone touching the server at midnight:

1. a token that has not expired beats one that has;
2. then the larger device ceiling wins — unlimited beats any number, so a
   mid-term upgrade applies the moment it is pasted;
3. then the later expiry, then the later issue date, then the more recent paste.

When *every* stored token has expired, the best of them still supplies the
device ceiling. A lapsed customer stays degraded at their paid size rather than
being cut to 2 devices, which would orphan a fleet they already enrolled.

The license page labels each row **In force**, **Superseded**, **Expired** or
**Invalid** accordingly.

## Clock rollback

Expiry is not evaluated against the raw system clock. The server records the
newest epoch-second it has ever observed in `server_state.clock_high_water` and
evaluates expiry against `max(system clock, that mark)`. The mark only ever
moves forward, enforced in SQL, and is persisted at startup and every 5 minutes
thereafter — so winding the machine's clock back does not bring an expired
license back to life. The mark is only maintained when the license actually
carries an expiry date; the free tier and perpetual licenses have nothing to
defend, so they skip it.

When the system clock is more than two minutes behind the mark, the server logs
a warning at each persist. If that is a *genuine* correction — a bad RTC or an
NTP glitch that once jumped the clock forward, poisoning the mark — clear it:

```sql
DELETE FROM server_state WHERE key = 'clock_high_water';
```

This defends against changing the machine's clock, which needs no tools at all.
It is not proof against someone willing to edit their own SQLite file; nothing
stored locally could be.

## Token format

One line of ASCII, three dot-separated fields:

```
WYRD1.<base64url_nopad(payload_json)>.<base64url_nopad(ed25519_signature)>
```

The signature covers the ASCII bytes of everything **before the last dot** —
that is, the string `WYRD1.<payload_b64>`. Signing the encoded form rather than
the raw JSON means the issuer and the server never have to agree on JSON
canonicalization, and it binds the version tag into the signature.

Blank lines and lines beginning with `#` are ignored, so you can staple a
human-readable header above the token and the customer can paste the lot:

```
# Wyrd license — Acme Corp
# Issued 2026-07-28, 100 devices, valid until 2027-07-28
WYRD1.eyJ2IjoxLCJjdXN0b21lciI6...
```

### Payload

```json
{
  "v": 1,
  "customer": "Acme Corp",
  "client_id": "3f2a9c14-8b7e-4d21-9a05-6c1f2e7b4d88",
  "max_devices": 100,
  "issued_at": 1753660800,
  "valid_until": 1785196800
}
```

| Field | Required | Meaning |
|---|---|---|
| `v` | yes | Format version. Must be `1`; anything else is rejected. |
| `customer` | yes | Licensee display name, shown in the dashboard sidebar. Free-form and unverified — see below. |
| `client_id` | yes | UUIDv4 identifying the customer organisation. Shown on hover, so a leaked license traces back to the account it was issued to. |
| `max_devices` | no | Device ceiling. Absent or `null` = unlimited. |
| `issued_at` | no | Epoch seconds. Informational; nothing reads it yet. |
| `valid_until` | no | Epoch seconds. Absent or `null` = perpetual, never expires or warns. |

`customer` is whatever the buyer wants to be called — a nickname, a handle, a
trading name or the full legal entity. Nothing validates it and nothing depends
on it: it is a label for the dashboard, not an identity claim, and two customers
may well pick the same one. Put the name the operator will recognise on their
own screen.

`client_id` identifies the *organisation*, not the token: issue one UUIDv4 per
customer when they first buy, then reuse that same value in every renewal you
sign for them. The server treats it as an opaque string — it is displayed and
never parsed — so nothing breaks if you deviate, but keeping it stable is what
makes a customer's licenses across the years line up as one account.

Unknown fields are ignored, so the format can grow without breaking old
servers — but note that old servers will silently ignore any new field you rely
on, so gate behaviour on `v` if that ever matters.

## Issuing

Reference implementation of the signing step (any Ed25519 library works —
there is nothing Rust-specific about the format):

```rust
let payload = r#"{"v":1,"customer":"Acme Corp","client_id":"3f2a9c14-8b7e-4d21-9a05-6c1f2e7b4d88","max_devices":100,"valid_until":1785196800}"#;

let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let signed = format!("WYRD1.{}", b64.encode(payload.as_bytes()));
let sig = signing_key.sign(signed.as_bytes());          // ed25519, over the ASCII bytes
let token = format!("{}.{}", signed, b64.encode(sig.to_bytes()));
```

Note the two different base64 alphabets: the token fields are **URL-safe,
unpadded**; the public key pasted into the server source is **standard base64
with padding** (that is just what the key looks like in most tooling).

## Trust anchor

`license_pubkey()` in [`src/license.rs`](src/license.rs) returns the Ed25519
public key every license is verified against.

The default in the tree is the **production** key. Its private half lives only
on the issuing machine and must never enter this repository.

The key sits inside `obfstr!()` rather than in a plain `const` so it is not a
44-character base64 landmark in `strings` output pointing straight at the
verification code. Keep it that way when you swap the value.

### Building against a different key

`WYRD_LICENSE_PUBKEY` replaces the compiled-in default at build time, so a demo
or test deployment is built against its own issuer instead of patching
`src/license.rs` from a Dockerfile — which broke on any reformatting of that
line:

```
cargo build --release                                  # the production key
WYRD_LICENSE_PUBKEY=<pubkey> cargo build --release     # that key instead
```

A development deployment is exactly that second form. The support portal signs
with the development pair whose public half is
`+AWCcb7T2Z5FBXtPtVSaC5gQNBCcX783HecjF1qERrE=` (MainWebPortal README), and its
private half is `test-license-signing.key` in the project root (gitignored,
along with the tokens minted from it in `test-licenses/`). A build that has to
accept portal-issued licenses passes that public half in:

```
WYRD_LICENSE_PUBKEY=+AWCcb7T2Z5FBXtPtVSaC5gQNBCcX783HecjF1qERrE= cargo build --release
```

Two things about this are deliberate:

- It is `option_env!`, read **at compile time**, never `std::env` at run time.
  The anchor is baked into the binary; there is nothing on the customer's
  machine that could point it at a different issuer.
- It **replaces** the default rather than joining it. A build trusting a second
  key would mean that one leak of the test private half mints licenses every
  production build accepts, forever.

Two more build-time overrides work the same way.

`WYRD_DPC_SIGNER_SHA256` replaces the digest of the certificate the device
client is signed with (`src/apk_signer.rs`). It exists so whoever builds the
client can run a server against their own debug-signed APK: the updater vets a
downloaded APK against this digest and enrollment hands it to Android, so
without the override a debug build is rejected at both points, and testing the
client would mean the production signing key leaving the machine that holds it.

`WYRD_PORTAL_BASE` replaces `https://my.wyrd.link` in the "Buy or renew" and
"Contact support" links; the server sends it to the dashboard as `portal_base`
on `GET /api/license`, so `web/js/license.js` needs no patching either. It
moves the clearnet half alone — the onion mirror beside it is named by its own
key, so there is nothing for a stand to substitute, and `src/links.rs` holds it
outright.

`packaging/build-deb.sh` refuses to run with any of the three set: a released
package must carry the values that are in the source.

### The "test build" mark

`build_info::is_untrusted_build()` reports whether *either* trust anchor — the
license key or the DPC signing digest — came from an override rather than the
compiled-in production value, and the dashboard stamps such a build **Test
build**: in the sidebar under the licensee line, and above the facts on the
license page. It is a positive marker on the untrusted build rather than a
"production" badge on the trusted one: a badge somebody forgot to remove looks
exactly like a production build, whereas this one cannot be forgotten into
silence.

The test is "was an override supplied", not a comparison against known
development values: the stand, the demo and the machine that builds the client
each use their own, and every one of them has to carry the mark. Only the
untouched production build goes unmarked.

## Release checklist

- [ ] `cargo test` green — `compiled_pubkey_is_a_usable_key` catches a mistyped
      paste into `license_pubkey()`, `the_anchor_matches_its_plain_text_copy`
      catches its two copies drifting apart, and
      `the_builtin_signer_digest_is_a_well_formed_sha256` does the same for the
      DPC digest
- [ ] Built with `--release` (fat LTO, one codegen unit, symbols stripped) and
      **without** `WYRD_LICENSE_PUBKEY`, `WYRD_DPC_SIGNER_SHA256` or
      `WYRD_PORTAL_BASE` set; `packaging/build-deb.sh` refuses to run if any is
- [ ] The dashboard does **not** show the *Test build* mark. This is what
      replaced "remember to delete `test-license-signing.key`": the development
      private key staying in the working tree is harmless now that the
      production key is the compiled-in one, and a build against any other
      issuer says so on every screen
- [ ] A test license issued by the production key verifies against the shipped
      binary

## What this does and does not defend against

Signed licenses make **forgery** impossible: without the private key, nobody can
write a keygen or hand-edit a device ceiling. That is the attack that actually
matters for a shareware release, and it is closed permanently. Keeping the
tokens in the operator's own database changes nothing there: every row is
re-verified against the compiled-in key on load, so editing one only produces a
token that stops verifying.

They do not stop **binary patching**. Every check is ultimately a branch, and a
determined reverse engineer can flip it. What raises the cost here is that the
checks are deliberately scattered and measured differently from each other
rather than centralised in one `is_licensed()` function:

- the device-create gate inside `create_device`'s transaction, where the count
  and the insert share one immediate transaction — which is what makes the
  limit race-free (it counts every reserved slot, enrolled or not)
- `/enroll` backstop (counts *enrolled* devices — a different number)
- expiry gates on each command-issuing endpoint
- `dispatch()` — blocks live delivery whatever queued the command; deleting a
  device is the one deliberate exception, since its `CANCEL_ENROLLMENT` frame
  is what releases the phone and must still go out in degraded mode
- the socket reconnect drain — blocks redelivery
- re-attestation scheduling, which pushes frames without going through
  `dispatch()`
- telemetry, ACK-payload (both the generic and the re-attestation path) and
  log-upload ingest gates
- the monotonic clock floor, so the expiry gates can't be sidestepped by
  changing the system time instead of the binary

Patching any one of these leaves the others enforcing. The realistic goal is to
make casual bypass more work than paying, not to be uncrackable — and for
self-hosted MDM infrastructure the buyers are businesses who need invoices,
support and security updates, which a cracked build cannot provide.
