think
16px
820px

Licence security / anti-piracy audit

Date: 2026-08-13 · Revision: 0b47c49 · Scope: the offline signed-licence scheme
(LICENSING.md, Program B) — trust anchor, verification, enforcement, and the practical ways a
customer defeats it.


Status (2026-08-13): all four findings are fixed. See "What was done" at the end. This
section is kept as written so the reasoning that motivated the changes stays legible.

Verdict

The cryptography is sound. The enforcement is not.

Forging a licence is infeasible without the vendor private key — the signing and verification design
is genuinely well built and I found no way to mint entitlements. But forgery is not how this product
gets pirated
. Four cheaper routes exist, and three of them need no tooling at all: an environment
variable, a clock, or simply never restarting the process.

The scheme currently stops an honest customer from accidentally over-consuming. It does not stop a
dishonest one.


What is genuinely strong — keep it

Control Why it holds
S1 Ed25519 over canonical bytes The verifier re-derives the canonical payload and byte-compares it to the signed bytes, with DisallowUnknownFields. No malleability, no field smuggling, no re-ordering attack.
S2 Build-pinned trust anchor releaseLicensePublicKey via ldflags is the only trusted key when set. LICENSE_PUBLIC_KEY is honoured only when OBSCURA_ENV=development and no release key is pinned — so the obvious "point it at my own keypair" bypass is closed.
S3 Dev-key fail-closed guard A non-development deployment still trusting the bundled dev key degrades to core-only. A release that forgets to pin cannot be unlocked by the in-repo dev key.
S4 Cloud intersects licence ∩ tenant A control-plane row cannot sell a module the deployment licence never covered. A DB write is not an entitlement.
S5 Key hygiene deploy/secrets/ is gitignored and the dev private key has never been committed (verified against full history). No licence artefact is tracked. The offline key ceremony is documented.
S6 Degrades, never crashes Missing / invalid / expired / node-mismatched all boot core-only. Air-gapped installs always start.

Findings

P1 — Seats is enforced nowhere · High (commercial)

The signed payload carries Seats. It is validated non-negative, logged at boot, and shown in
Admin → Licensing. No code path ever compares it to the number of accounts.

A 25-seat licence runs 25,000 users with no warning, no degradation, and nothing in the audit log.

Correction to this finding. A SeatGuard port already exists on the auth service and is
wired in Cloud to the per-tenant plan quota — including a well-argued choice of which
provisioning paths it guards (self-registration, admin create, SCIM — and deliberately not
first-time SSO sign-in). It is nil in Enterprise, "where seats are the licence's business",
and the licence side was never built. So the gap is real, but the fix is much smaller than
this finding implied: wire the existing port, and do not re-litigate the path selection.

Not to be confused with Cloud's per-tenant account ceiling (EffectiveSeats,
tenancy/domain/plan.go, migration 00011) — that is a quota mechanism for SaaS plans and is
unrelated to the Enterprise licence field. Enterprise has no equivalent.

This is the single biggest commercial leak, because seat count is how the product is priced and it is
the number a customer most easily under-declares. It is also the easiest to fix — the count is one
query.

Fix: enforce at account creation/activation. Refuse the new account past the ceiling rather
than disabling existing ones (never lock a customer out of their own archive), warn in Admin at
~90%, and audit-log every refusal. A small grace band above the paid number is normal and avoids a
support call at exactly the wrong moment.

P2 — Node-lock is self-asserted, so it stops nobody · High

func (c Config) DeploymentFingerprint() string {
    if id := strings.TrimSpace(c.License.NodeID); id != "" { return id }   // OBSCURA_NODE_ID
    if b, err := os.ReadFile("/etc/machine-id"); err == nil { ... }
    return ""
}

The node identity a node-locked licence is checked against is an environment variable the customer
sets
. And they do not even have to guess the value: node_lock is in the licence file on their own
disk and returned by GET /api/v1/admin/license.

So the complete bypass is:

OBSCURA_NODE_ID="<the node_lock string from my own licence>"

Copy the licence to as many hosts as you like. Node-lock currently prevents accidental reuse
(someone restoring a backup onto a second host), which has value — but as an anti-piracy control it
is decorative.

Fix, in order of effort:
1. Stop echoing node_lock back through the admin API — make the operator prove the node, not read the answer.
2. Derive the fingerprint from something not settable by env: /etc/machine-id (and/or a hash of stable hardware/volume identity), with OBSCURA_NODE_ID demoted to a documented escape hatch that is logged loudly whenever it is what satisfied the lock.
3. Salt the fingerprint hash with a build-time secret so the value cannot be precomputed from the licence alone.

None of these make it unbreakable. All of them raise it from "set one env var" to "patch the binary",
which is the honest goal.

P3 — Expiry is evaluated once, at boot · Medium-High

LoadLicense(..., time.Now().UTC()) runs in the composition root. After that, moduleEnabled reads
a cached snapshot:

func (s *Server) moduleEnabled(ctx context.Context, module string) bool {
    if !s.lic.Load().modules[module] { return false }
    ...
}

There is no expiry check on that path and no ticker that re-evaluates. A process that stays up
keeps every premium module indefinitely past its expiry date.

A deployment updated via deploy/obscura update restarts and would catch it. A stable production
box that is not touched for a year does not — and "don't restart the server" is not a sophisticated
attack.

Fix: have moduleEnabled consult the expiry (cheap — one time comparison against a cached
value), or run a periodic re-evaluation that swaps the snapshot. The former is strictly better: it
cannot drift.

P4 — No clock-rollback defence · Medium

Expiry compares against the system clock. Setting the container's clock back is enough to make an
expired licence load as valid — and in Docker the clock is inherited from the host, which the
customer owns.

Fix: persist a monotonic high-water mark (the latest time the deployment has ever observed, in
the database) and refuse to honour a licence when now is meaningfully behind it. Cheap, offline,
and it turns a five-second attack into one that also requires falsifying database state.

P5 — Binary patching · Low, accept explicitly

Any offline scheme can be defeated by patching moduleEnabled to return true. This is inherent —
the check runs on hardware the attacker controls — and chasing it with obfuscation is an arms race
with poor returns for an enterprise DMS sold to institutions.

Worth stating as an accepted risk rather than leaving implied. The real deterrents for this
buyer are commercial and reputational: support, updates, indemnity, and the fact that the customers
are government and corporate bodies for whom running a cracked binary is a procurement problem, not
a technical one. What is worth adding is detectability — see the recommendation below.

P6 — The licence file is mounted read-write · Informational

deploy/docker-compose.yml mounts obscura.license.json read-write so an admin upload persists
across restarts. A container compromise could therefore rewrite it — but the replacement still has
to carry a valid vendor signature, so this grants nothing an attacker did not already have. Noted
for completeness; no change recommended.


Recommended order of work

  1. P1 seats — biggest commercial leak, smallest change, no protocol change needed.
  2. P3 expiry-at-request — a few lines, removes an indefinite-entitlement bug.
  3. P2 node-lock hardening — start with (1) and (2) from its fix list; both are small.
  4. P4 clock high-water mark — needs a migration, so batch it with other schema work.
  5. Detectability, not prevention — record the licence status, customer, module set and node
    fingerprint into the tamper-evident audit chain at every boot. It does not stop a patched binary,
    but it means a deployment that has been tampered with cannot also present a clean audit history
    during a support engagement or an audit — which for this buyer is the sanction that actually bites.

Not covered

  • The Cloud control-plane's own authentication (CONTROL_PLANE_TOKEN) — separate surface.
  • Whether the production key ceremony was actually performed as LICENSING.md documents; that is an
    operational fact, not a code one.
  • The 17 open Dependabot advisories on the default branch (11 high) — unrelated, but they are
    supply-chain risk against the same binary that enforces all of the above.

What was done

All four are fixed; the audit harness and regression tests are in the tree.

Fix
P1 The existing SeatGuard port is now wired in Enterprise to the licence's own Seats, guarding the same three provisioning paths Cloud guards (cmd/obscura-server/seatguard.go).
P2 A strong node-lock form: a node_lock carrying the db: prefix is matched only against an identity derived from the Postgres cluster's system_identifier, which an environment variable cannot satisfy. The admin API no longer discloses a legacy lock's value — that string was the bypass, and returning it completed the attack for the reader.
P3 The term is evaluated on every gate read rather than once at boot, and /me reports the effective status so the UI never shows "valid" for a licence the gate is refusing.
P4 Migration 00190 records the latest instant the deployment has ever observed; a clock implausibly behind it withholds premium modules (internal/licenseclock).

Two things the implementation turned up

/etc/machine-id does not exist in the distroless image. The fingerprint's machine-id
branch has therefore never produced a value in any containerised deployment, leaving
OBSCURA_NODE_ID as the only working source. P2 was worse than written: node-lock had
exactly one input and it was the customer-settable one. That is why the strong identity is
derived from the database cluster rather than from the host.

Backward compatibility and hardening were in direct conflict. Accepting several identities
for compatibility would mean the weakest one is always available, so hardening would achieve
nothing. Resolved by putting the strength in the licence: the db: prefix selects it. Every
licence already in the field keeps working untouched, and the vendor chooses the binding when
minting a new one — no flag day, no reissue.

Air-gapped behaviour

Every control here is offline by construction — no phone-home, no clock server, no vendor
round-trip — and every one is biased to fail toward the honest operator, because on an
air-gapped site nobody can be phoned when a control is wrong:

  • Seats fail open. A failed count allows the account; only new accounts are ever
    refused, so nobody is locked out of an archive and the remedy is entirely local.
  • The clock witness tolerates 48 hours of drift — a dead RTC, a first boot before NTP, or a
    timezone correction is not tampering — and a watermark that cannot be read is never treated
    as evidence. When it does fire, it withholds premium modules; core keeps serving.
  • The term withdraws premium modules only, and a renewal installs as an offline file.
  • The node identity needs no network to compute and is printed at every boot, so an
    operator on an isolated box can read it to their vendor to obtain a properly bound licence.
    ./deploy/obscura diagnose prints it too.

The one thing that does require care: a db:-locked licence is bound to the database
cluster
. Restoring into a freshly initdb'd cluster produces a new identity and needs a
reissued licence. That is the property that makes the lock meaningful, but it must be on the
DR runbook — see docs/BACKUP.md.