think
16px
820px

Obscura vendor licence portal

cmd/obscura-licence-portal — the VENDOR's internal licence service. Registry of
customers and deployments, sealed-key minting, expiry/drift dashboard, and the server
half of LICENCE_SERVER_API.md (POST /v1/licence/check).

Never shipped to a customer. It is not the operator console (customers receive that
in Cloud). It lives in this repo — not its own — for exactly one reason: minting must
produce byte-identical canonical payloads, so it imports internal/platform/config's
marshaller, module catalogue and alias table instead of copying them. A separate repo
would re-implement those and drift; drift here mints licences nobody can install.

What it replaces

The SSH ritual: paste the Ed25519 private key on a command line (→ shell history),
hand-type the module list (→ alias trap), copy the file through a throwaway container
(uid 65532), restart. Seven steps, four documented footguns. Now: a form, a passphrase,
a download link — and delivery over the check channel needs no hands at all.

Running it

cd go/cmd/obscura-licence-portal
PORTAL_DB_PASSWORD= docker compose up -d --build
docker compose run --rm portal -bootstrap-user you@virtue.digital   # prints a one-time password

Env: DATABASE_URL (required — its OWN database, never a product's), PORTAL_ADDR
(default :8090), PORTAL_INSECURE_COOKIES=1 (local dev over http only).

Put it behind the edge nginx with TLS. Only /v1/licence/check must be reachable from
customer networks — it is the one address an air-gapped site is asked to whitelist.

Sign-in

Password plus TOTP, mandatory, no exemptions — every account here can reach the mint
form, so there is no equivalent of the operator console's password-only viewer.

First sign-in routes through enrollment: the portal generates a secret, renders the
otpauth QR server-side (before any session exists), and only confirms enrollment once
you prove possession with a code. Between password and code you hold a pending
session, which is excluded from the authenticated lookup at the SQL level — it can do
exactly one thing: finish its own login. Codes are single-use, so one observed in
transit cannot be replayed inside its 30-second step.

Lost your authenticator? There is no self-service reset. Clear it in the database
(UPDATE licenceportal.users SET totp_secret = '', totp_confirmed = false WHERE email = '…') and the next sign-in re-enrolls.

The keystore

Private keys are stored ONLY as authenticated ciphertext (argon2id → XChaCha20-Poly1305,
the anchor identity bound in as AAD). Import pastes the base64 key into a form over
HTTPS and seals it under a passphrase; minting decrypts it in memory for the
milliseconds a signature takes. The key never touches a command line, a log, or a disk
in the clear. There is no passphrase recovery — forgetting it means re-importing the
key, which is the correct failure mode (a recoverable passphrase would be a second copy
of the secret).

Two anchors, imported separately, chosen per deployment at mint time:

anchor private key source verifies on
release the user's key — deliberately on no server until imported here prod dms1, VM2 dms2 (env sets LICENSE_PUBLIC_KEY)
dev deploy/secrets/license_dev_ed25519.key demo/local builds (bundled anchor)

🔴 The anchor is recorded per deployment and nothing implies it — not the edition,
not the customer. The mint path refuses a key whose anchor differs from the
deployment's, which is the trap that used to hand customers uninstallable licences.

Import guards: a dev-anchor import must derive exactly the anchor this build bundles;
a release-anchor import requires the expected public key (Pui41a9vw…UFbg=) —
the portal has no other way to know the release anchor, and sealing the wrong key
there would self-verify happily while minting licences no customer can install.

Minting rules the portal enforces

  • Renewals decode the stored bytes of the licence being replaced — never a boot log
    (ModuleAliases expands mcpai inbound), never retyping. A payload that says
    mcp renews saying mcp: same behaviour, same bytes.
  • Retired module names are offered only when the previous payload already carries them,
    annotated ("retired name — grants ai"), and signed verbatim.
  • A customer-name change or an expiry regression needs an explicit confirmation
    checkbox — the client refuses both at install (contract §4), so silently minting
    either would produce a licence the deployment rejects on arrival.
  • Every minted file is round-tripped through config.VerifyLicense against the
    anchor's public key before it is stored. Nothing unverifiable is ever issued.

The check endpoint

Advisory, per the contract's §0 — the signed file on the deployment's disk stays the
sole authority, and a deployment that never calls is a supported mode shown as
"never checked", not a fault.

Server behaviours worth knowing:

  • Token → deployment. An unknown token answers 200 {"status":"unknown"} — neutral,
    unrecorded, never an accusation.
  • First contact learns the deployment's db: identity. A later mismatch (DR
    restore into a fresh cluster) answers unknown and is surfaced as drift with a
    one-click "Adopt reported identity" re-point — never silently adopted.
  • A deployment reporting licence bytes that expire LATER than what we would send gets
    current (pushing our older file is guaranteed to be refused client-side); the drift
    dashboard surfaces it for a human, with an import flow to adopt the outside mint.
  • An expired or wrong-anchor issued licence is never served.
  • Fingerprints are accepted for BOTH byte forms of the same signed document: the exact
    stored file (downloaded, installed by hand) and the JSON-compacted wire form (the
    licence value as it crosses the check response, persisted verbatim). Client note:
    persist the licence value's raw bytes as received and fingerprint those; both
    forms then read as current.

"Check now" — the portal-side button, and why it is a long-poll

The portal never dials a deployment. That is deliberate and is not going to change: it
is the property that lets a customer run Obscura behind NAT, inside a corporate network,
or fully air-gapped without the vendor holding an inbound door.

So the button works the only way it safely can. Pressing it arms a flag; the deployment
collects it from an outbound long-poll it holds open itself (POST /v1/licence/wake,
contract §7), then runs its own ordinary check.

The button states what it will actually do, because a button that always claims success
is worse than no button:

what you see what it means
green dot, "wake channel open" the deployment is connected — expect it to report within seconds
grey dot, "no wake channel open" older build, or outbound blocked — the request waits for the next scheduled check
"requested" badge armed and not yet collected; reload to see the result
"no check token issued" this deployment never calls in, so it cannot be asked to. Issue a token first

A deployment needs the client half (branch worktree-licence-checknow) for the fast
path. Until that ships, every fielded deployment shows the grey dot, and the button is
still correct — the request is simply collected at the daily check.

Requests are delivered exactly once: any check that lands clears the flag, so an
impatient operator cannot queue five checks.

Data & retention

Check records hold exactly the contract's request fields — no document data, no tenant
names, no user identities — and are pruned after 400 days. That is the stated
policy for the first government buyer who asks.

The portal database contains the registry, every issued licence's exact bytes, and the
sealed signing keys. Back it up accordingly; the sealed keys are useless without their
passphrases, but losing the database means re-importing keys and re-baselining every
deployment from its installed licence file.

Baselining existing deployments

Two paths, both running the same verified import — the licence must verify against the
anchor recorded for that deployment, and its exact bytes become the record renewals
decode from.

UI: Import existing licence on the deployment page. Upload the file rather than
pasting
— the fingerprint is over exact bytes and upload preserves them.

CLI (no session needed, so it works before anyone has enrolled):

docker compose -p licence-portal run --rm -v /path/to/licences:/baseline:ro portal \
  -baseline -customer "PT Virtue Digital Indonesia" -deployment "prod dms1" \
  -edition cloud -anchor release -licence /baseline/dms1.license.json

Customer and deployment are reused if they already exist, so a re-run is safe. The
container runs as uid 65532: the staged files need to be readable by it (0644 is
correct — a licence is a public signed artifact; only the private key is secret).

⚠️ Getting a licence file OFF a deployment: on some hosts it is 0600 owned by uid
65532 and the ssh user cannot read it. Pull it through the container instead:
docker cp deploy-obscura-1:/run/secrets/obscura.license.json - | tar -xO.

Turning the check on for a deployment

  1. Issue the token — UI (Issue token, shown once) or CLI:
docker compose -p licence-portal run --rm portal \
  -issue-token "x056 demo" -customer "PT Virtue Digital Indonesia"
  1. Put both variables in the env file that deployment actually deploys with:
LICENSE_CHECK_URL=https://licence.getobscura.id/v1/licence/check
LICENSE_CHECK_TOKEN=odt_…
  1. Recreate the container with that same env file, then confirm the boot line
    licence check enabled (advisory …) and its reported deployment_id.

🔴 .env is IGNORED when an explicit --env-file is passed. The x056 demo deploys
with --env-file mekari.env, so deploy/.env is dead weight there — putting the
variables in it changes nothing.

🔴 Worse: recreating a container with the WRONG (or no) --env-file silently drops
every setting that file supplied.
docker compose -p deploy up -d obscura with no
--env-file took the demo down: compose defaults OBSCURA_ENV to production, the
production boot guard found unmet security requirements, and the container crash-looped.
Always recreate the way that host deploys:

cd deploy && docker compose --env-file mekari.env -p deploy up -d obscura

An env file states an intention; only docker inspect states a fact. Check the result:

docker inspect deploy-obscura-1 --format '{{range .Config.Env}}{{println .}}{{end}}' \
  | grep -E 'OBSCURA_ENV|LICENSE_CHECK'

The first check fires 1–6 minutes after boot (jittered), then daily.

🔴 On a GHCR-deployed host, OBSCURA_SERVER_IMAGE is un-persisted and recreating
without it ROLLS THE HOST BACK.
Base compose reads
image: "${OBSCURA_SERVER_IMAGE:-deploy-obscura}"; VM2 runs GHCR nightly images and that
variable is not in cloud.env, so a plain recreate silently swapped in a stale local
image (v1.0.1-7 → 554fbd66) that predates the check client entirely. It boots clean and
reports "valid" — only the boot log's version gives it away. Read the running tag
first (cat /run/obscura-updater/status.json) and pass it:

OBSCURA_SERVER_IMAGE=ghcr.io/virtue-digital-indonesia/obscura-server:<running> \
  docker compose --env-file cloud.env \
    -f docker-compose.yml -f docker-compose.updater.yml -p deploy up -d obscura

Compose files and env files differ per host — read the container's
com.docker.compose.project.config_files label instead of assuming.

Deployments vs tenants — what the registry does NOT model

The registry holds deployments, never tenants. A Cloud deployment's licence is an
envelope; who lives inside it is control.tenant_modules, and the operator console owns
that. Concretely, prod dms1 is one deployment holding one licence, and serves two
tenants — dms (Virtue Digital Indonesia) and sakti (PT Sakti Anugerah Sentosa) —
both drawing from that single envelope. sakti.val.id is a Host-routed tenant on dms1,
not a deployment, which is why it has no row here and no licence of its own.

The effective set a tenant gets is licence ∩ control.tenant_modules. Reissuing a
licence alone changes nothing for a tenant, and the portal will never show or edit the
per-tenant half.

Current registry (baselined 2026-08-20)

One customer, PT Virtue Digital Indonesia, owning all three fielded deployments. The
payload customer strings differ per licence and are preserved verbatim — the registry
groups by legal entity, the payload says whatever was signed, and a renewal carries the
payload's own string forward.

deployment edition anchor modules seats expiry
prod dms1 cloud release all 8 40 2035-12-31
VM2 cloud getobscura.id cloud release correspondence, office, contracts 0 2027-12-31
x056 demo enterprise dev all 8 100 2030-01-01
deployment node identity
dms.val.id — Cloud Enterprise (dms + sakti) db:cfc0d84937ccfad09186e19b3ebc764e
getobscura.id — Obscura Cloud (SaaS) db:3c221cb144f3ec52b4dee3e14ef87a23
x056 demo db:c68132a64c3cd09c01adb0d52386e9f7

Check wiring, as of 2026-08-20

All three deployments are wired to https://licence.getobscura.id/v1/licence/check and
reporting. dms.val.id was wired last, on 2026-08-20: it runs v1.1.1, which carries
the check client but NOT the wake client, so its "Check now" shows the grey dot until a
build from worktree-licence-checknow lands.

Wiring a deployment sets LICENSE_CHECK_URL and LICENSE_CHECK_TOKEN. 🔴 Both are
env, which is baked into a container at CREATE time — docker restart will not pick
them up. The container must be recreated, which on prod means passing the full compose
set, the right --env-file, AND an explicit OBSCURA_SERVER_IMAGE: the compose default
is ${OBSCURA_SERVER_IMAGE:-deploy-obscura}, so an unpinned recreate silently swaps a
GHCR release for whatever stale local image exists.

Node identity — where it comes from

The registry shows "not yet learned" until either the deployment calls in for the first
time (the normal path) or an operator asserts the value. Nothing is wrong when it is
blank; it just means that deployment has never checked in, which for a fleet whose
client side is not yet configured is every deployment.

You do not have to wait for it. The identity is derivable — the deployment prints it
at boot, and it can be recomputed from the database cluster:

db: + first 16 bytes of sha256("obscura-node-identity:" || <system_identifier>)

Read it straight from the deployment's log (authoritative, zero derivation risk):

docker logs deploy-obscura-1 2>&1 | grep "node identity"
→ "database":"db:cfc0d849…","env_node_id":"obscura-prod-vm1","machine_id":""

or recompute it from Postgres:

docker exec deploy-postgres-1 psql -U obscura -d obscura -tAc \
  "SELECT system_identifier FROM pg_control_system()"

then -baseline … -node-id db:<digest> sets it (an explicit value wins over the stored
one, which is also how a DR-restored cluster is re-pointed).

⚠️ machine_id is empty in that log line on every deployment — /etc/machine-id does
not exist in the distroless image. That is expected, not a fault; the db: identity is
the strong one precisely because it does not depend on it.

Knowing the identity before first contact is what makes it possible to node-lock a
licence at mint time rather than waiting a day for the deployment to introduce itself.

Accounts

-rename-user old@example.com -to new@example.com changes an address without touching
the TOTP secret, so the enrolled authenticator keeps working (its label still shows the
old address until re-enrolled). There is no self-service password change yet.