Module Licensing (Program B)
Obscura is sold as a fat core + premium modules. Premium modules are unlocked by a
cryptographically signed, offline license file verified at boot. This is the
ROADMAP P2 "Software anti-piracy / licensing" workstream. It works fully air-gapped:
no phone-home, no network dependency.
Decision (locked, user-approved): entitlements live in a signed license file, not a
config flag — "hard to enable if not by us". See theobscura-modularity-packaging
memory andCORRESPONDENCE_PARITY.md→ PROGRAM B.
Premium modules
correspondence, watermarking, ai, esign, semantic. Everything else is core and always
available (documents, RBAC, workflow, retention, reporting, …). A premium module may
depend on core but not on another premium module.
| Module | Description |
|---|---|
correspondence |
e-Office surat-menyurat (inbound intake, disposisi, outbound drafting, lifecycle). |
watermarking |
Forensic steganographic watermark embedded invisibly in PDFs at download. |
ai |
AI-assisted features (summarisation, classification suggestions, Q&A). |
esign |
Electronic signature workflows (Mekari Global OTP, e-Meterai, multi-party, external signers). |
semantic |
Semantic folder suggestion (AI smart-filing): pgvector embedding index over documents; suggests the best folder at upload time. |
How it works
- Boot —
config.LoadLicense(path, publicKey, now)readsLICENSE_FILE, verifies the
Ed25519 signature against the embedded public key, checks expiry, and returns a
LicenseInfo{Status, Customer, Modules, Seats, Expiry, NodeLock}. - Degrade gracefully — a missing / malformed / wrong-signature / expired license is
not fatal. The server boots core-only (status=missing|invalid|expired,
modules=[]). Air-gapped installs always boot. - Enforce —
requireModule("<name>")(chi middleware,internal/httpapi/licensing.go)
wraps each premium route group; a disabled module returns403 module.not_licensed.
This is the real enforcement point. - Surface —
GET /mereturnsenabled_modules+ alicenseobject. The web UI hides
unlicensed nav/features (cosmetic) and Admin → Licensing shows the current license.
Signed envelope format
LICENSE_FILE is JSON:
{ "payload": "<base64(canonical-json payload)>", "signature": "<base64(ed25519 sig)>" }
The payload (after base64-decode) is the canonical JSON of:
{ "customer": "...", "modules": ["correspondence","ai"], "seats": 100, "expiry": "2026-12-31T23:59:59Z", "node_lock": "" }
The signature is Ed25519(privateKey, payloadBytes). Verification recomputes the canonical
payload and rejects anything non-canonical, so the bytes that are signed are unambiguous.
Keys — trust anchor
The verify key (which public key the server trusts) is resolved by
Config.LicenseVerifyKey() in this order:
releaseLicensePublicKey— a package var pinned at build time via ldflags. When set,
it is the only trusted key and cannot be overridden by the customer environment.
This is what makes entitlements "hard to enable if not by us".LICENSE_PUBLIC_KEYenv — honored only whenOBSCURA_ENV=developmentand no
release key is pinned. A dev convenience; ignored in production.- bundled dev key (
devLicensePublicKey) — the fallback. Its private half is in
deploy/secrets/license_dev_ed25519.key(gitignored), used to sign dev/demo licenses.
Fail-closed guard: if the effective key is the bundled dev key and
OBSCURA_ENV != development, the server logs a loud warning and degrades to core-only
(the dev/demo licenses cannot unlock premium modules on a release that forgot to pin a key).
⚠️ Why this matters: without pinning, a customer could mint their own keypair, self-sign
an all-modules license, and pointLICENSE_PUBLIC_KEYat their own public key — enabling
everything with no vendor secret. Pinning + the dev-key guard close that bypass.
Enforcement beyond the module list
The signed payload carries more than modules, and all of it is now enforced. Each control is
offline by construction and biased to fail toward the honest operator, because an air-gapped
customer cannot be phoned when a control is wrong.
Seats — Seats caps live human accounts (soft-deleted rows and service accounts are not
counted; disabled accounts ARE, or "disable, add, re-enable" would be an unlimited licence). It
guards the three deliberate provisioning paths — self-registration, admin create, SCIM — and
deliberately NOT first-time SSO sign-in, which would present as "SSO is broken" to someone who
can do nothing about it. Seats: 0 is unlimited. A failed count ALLOWS the account: this is a
commercial ceiling, not a security control.
Term — expiry is evaluated on every entitlement check, not once at boot. A deployment that
never restarts does not keep premium modules past its term. Core is unaffected.
Clock — the deployment records the latest instant it has ever observed (migration 00190). A
clock more than 48 hours behind that watermark cannot be used to measure a term, so premium
modules are withheld until it is corrected. The tolerance is generous on purpose: a dead RTC or
a first boot before NTP must not be mistaken for tampering.
Node lock — two forms:
node_lock value |
Matched against | Strength |
|---|---|---|
db:<digest> |
the Postgres cluster's system_identifier |
binding — needs a new cluster to move, i.e. abandoning the archive |
| anything else | OBSCURA_NODE_ID, else /etc/machine-id |
legacy — constrains accidental reuse only |
Prefer db:. The legacy form is matched against a string the customer sets and can read out of
their own licence, so it does not survive a determined copy. Note /etc/machine-id is absent
from the distroless image, so in a containerised deployment OBSCURA_NODE_ID is the only
legacy input.
The target prints its identity at every boot (deployment node identity) and
./deploy/obscura diagnose shows it, so an operator on an isolated network can read it to you.
Mint with licensegen -node-lock 'db:<digest>'.
The admin API discloses a db: binding in full — no operator-settable value satisfies it, and
the operator needs to read it to request a renewal. A LEGACY binding is reported only as the
fact of being locked: returning that string handed an administrator the exact value to paste
into OBSCURA_NODE_ID on a second host, i.e. the API completing the bypass on their behalf.
⚠️ A
db:-locked licence is bound to the DATABASE CLUSTER. Restoring into a freshly
initdb'd cluster yields a new identity and needs a reissued licence. Put this on the DR
runbook.
Production build
# 1. Generate the production keypair ONCE; keep the private key strictly offline (Val).
go run ./cmd/licensegen -gen-key
# 2. Pin the public key into the release binary:
go build -trimpath \
-ldflags "-s -w -X github.com/Virtue-Digital-Indonesia/obscura/internal/platform/config.releaseLicensePublicKey=<PROD_PUBLIC_KEY>" \
-o obscura-server ./cmd/obscura-server
Add the same -X to deploy/Dockerfile for release images. Never ship the dev private
key or the 2099 demo license in a release bundle (both are gitignored — keep them out of
deploy artifacts).
Key ceremony (custody: Efran — decided 2026-07-04)
Run ONCE before the first GA build, ~15 minutes. The private key is the commercial root of
trust: whoever holds it can mint any license for any customer. It must never touch a
networked machine after generation.
- Prepare an offline machine — a laptop with Wi-Fi/Ethernet off (airplane mode; ideally
booted from a live USB), with Go and this repo copied over via USB. - Generate:
cd go && go run ./cmd/licensegen -gen-key→ notepublic_key=and
private_key=(base64 Ed25519). - Store the private key (never on a networked box):
- primary: an encrypted USB stick (or hardware token) kept by Efran;
- backup: the base64 string printed/hand-written on paper, sealed in an envelope in a
safe (this is also the succession plan — label it);
- optional second encrypted USB stored separately from the paper copy. - Carry only the PUBLIC key back to the build machine and pin it:
make release-image LICENSE_PUBLIC_KEY=<PROD_PUBLIC_KEY>(release builds reject dev-key
licenses; the fail-closed guard covers a forgotten pin). - Mint customer licenses on the offline machine only: bring the customer request
(name, modules, seats, expiry, node id) to it via USB, runlicensegenthere, carry the
signed.license.jsonback. Verify withlicensegen -verifybefore delivery. - Wipe any shell history / temp files on the offline machine that contain the key.
Rotation (only if compromise is suspected): generate a new pair, re-pin, re-issue every
customer license, ship updated builds — expensive, so custody discipline beats rotation.
CLI — go/cmd/licensegen
cd go
# 1. Generate a keypair (prints public_key= and private_key=)
go run ./cmd/licensegen -gen-key
# 2. Issue a license (sign with the private key)
go run ./cmd/licensegen \
-private-key "<base64 private key>" \
-customer "Acme Corp" \
-modules correspondence,ai,esign \
-seats 100 \
-expiry 2026-12-31 \
-out deploy/secrets/obscura.license.json
-expiry accepts YYYY-MM-DD (→ end of that day UTC) or full RFC3339.
Deployment
deploy/docker-compose.yml mounts ./secrets/obscura.license.json →
/run/secrets/obscura.license.json and sets LICENSE_FILE to it (gitignored, like
blob_age.key). Generate the file before docker compose up, or the stack boots
core-only.
Env vars:
| Var | Meaning |
|---|---|
LICENSE_FILE |
path to the signed license JSON (unset/missing ⇒ core-only) |
LICENSE_PUBLIC_KEY |
dev only — base64 Ed25519 verify key override; honored only when OBSCURA_ENV=development and no release key is pinned. Ignored in production. |
The legacy
ENABLED_MODULESenv stub has been removed. A valid license's signed
modules[]is the sole, authoritative entitlement — there is no config-flag override.
Status: what's done vs. remaining
Done (B1–B5 + live upload + GA build pinning):
- B1 — signed license format + Ed25519 verify at boot, graceful core-only degrade, licensegen CLI.
- B2 — requireModule enforcement on all premium routes (correspondence, ai, esign;
watermarking has no routes yet). moduleEnabled reads the verified license; the signed
modules[] is authoritative (no env override).
- B3 — /me exposes enabled_modules + license (detail fields admin-only); UI nav/feature
gating reads it.
- B4 (view) — Admin → Licensing read-only view. Tests: internal/platform/config/license_test.go
(verify logic) + internal/httpapi/licensing_test.go (DB-free requireModule 403 enforcement).
- B4 (upload) — POST /admin/license (license.admin). Verifies an uploaded signed
license against the SAME trust anchor, then HOT-SWAPS entitlements live (no restart):
Server.lic is an atomic.Pointer[licenseState], swapped on a valid upload, so /me,
requireModule and the licensing view reflect the new license immediately. Persists
best-effort to LICENSE_FILE (reports persisted) so it survives a restart; invalid/
expired uploads are rejected and change nothing; the dev-key-in-prod guard degrades a
valid upload to core-only. Admin UI: an upload control in Admin → Licensing.
- Deploy note: for persistence the LICENSE_FILE must be writable by the runtime
user (distroless nonroot, UID 65532). The compose mount is read-write; ensure the
host file is writable by that UID (e.g. chmod 666 the demo license, or chown 65532).
If it isn't, upload still hot-swaps live but persisted=false (reverts on restart).
- Trust-anchor hardening — release key pinning via ldflags, LICENSE_PUBLIC_KEY dev-only,
dev-key-in-prod fail-closed guard.
- GA build pinning (mechanism). A release build pins the production trust anchor so it
trusts ONLY vendor-signed licenses (the in-repo dev key is no longer honored):
- make release-build LICENSE_PUBLIC_KEY=<base64> → go/bin/obscura-server, and
make release-image LICENSE_PUBLIC_KEY=<base64> → image obscura-server:ga
(the Dockerfile takes a LICENSE_PUBLIC_KEY build-arg; empty = dev build / dev key).
- licensegen -verify <file> verifies a license against THIS build's trust anchor
(config.BuildTrustAnchor() / IsReleaseBuild()), so an operator can confirm a GA
binary accepts the prod-signed license and rejects dev-signed ones. Verified: a release
build pinned to key K rejects the dev-signed demo license and accepts K-signed licenses;
a dev build still trusts the dev key + demo.
- B5 — node-lock enforcement + seats advisory.
- node_lock: a license with a NON-EMPTY node_lock is honored only on the matching
node. The deployment fingerprint is OBSCURA_NODE_ID (operator-set, preferred for
air-gapped determinism), else /etc/machine-id, else "" (config.DeploymentFingerprint).
config.NodeLockSatisfied(nodeLock, fingerprint): an EMPTY node_lock is UNLOCKED (always
satisfied — the demo + common case); a non-empty node_lock requires an exact match (so a
locked license on an unidentified host fails closed). Boot mismatch degrades to
core-only; upload mismatch is rejected 400 leaving the running license in force (a
bad upload never breaks the deployment). Verified end-to-end (match→applied, upload
mismatch→400 unchanged, boot mismatch→core-only, restore→all 4; empty node_lock never
locks the demo). node_lock lives in the SIGNED payload, so it can't be flipped off.
- seats: ADVISORY ONLY — surfaced in /me license detail (admin) + the boot log
(license_seats). NOT hard-enforced (no login/route block), so it can never lock out a
customer; hard enforcement is deliberately deferred.
Program B core is COMPLETE (B1–B5 read + enforce + live upload + GA build pinning).
What remains below is operational handover + optional follow-ups, not core licensing.
Remaining (handover / follow-ups):
- GA key generation (operational, vendor action — NOT a code task). Generate the
production keypair ONCE with licensegen -gen-key on an offline machine; the private
key is held strictly by the vendor (HSM / offline vault) and must NEVER touch a build
machine or this repo. Build GA artifacts with make release-image LICENSE_PUBLIC_KEY=…
(only the PUBLIC key is needed to build). The demo/dev key + demo license stay out of
release bundles. (Awaiting the vendor's decision on where the prod private key lives.)
- Optional (future, never required offline): binary integrity check; online
activation/heartbeat + kill-switch (air-gapped installs must always boot without them).
Done (post-B5 hardening):
- Sign-step fail-fast. The no-code workflow designer could author/start a
StepKindSign step on an esign-unlicensed deployment, creating a pending sign task no
one can complete. The workflow Service now fails fast: a ModuleChecker port (wired to
the live, hot-swappable license on the HTTP server) backs requireSignLicensed, which
scans steps in DefineWorkflow/ReviseWorkflow (author time) and StartFromDefinition
(the license-downgrade case) and returns module.not_licensed when esign is off. The
standalone RequestSignature path is already requireModule("esign")-gated at the
route. Verified live (esign off → Sign-step define 403, approve define 201; esign on →
Sign-step define 201); the demo's 4 modules are unaffected.