For the complete documentation index, see llms.txt. This page is also available as Markdown.

๐Ÿ›ก๏ธProtected Plugins

The strong mode: your compiled code never lands on the customer's disk, and runs only in memory after a successful activation. Paper, Velocity, or plain Java.

In this mode customers never receive your jar. They install a thin loader. On every start the loader activates the license and the platform returns your encrypted code, which the loader decrypts and runs entirely in memory.

It is the strongest option and the most invasive one: your code has to implement one interface, and your customers install the loader instead of your plugin. If you only need licensing (seats, expiry, revocation, entitlements), the license check gets you there with one method call.

It runs anywhere

There are three hosts, and they share one implementation. The activation, signature verification, decryption and in-memory class loading are the same code in all three; only the surrounding lifecycle differs.

Host
What the customer installs
Your module gets

Paper (Bukkit, Spigot)

The loader plugin

PluginContext: listeners, commands, the host plugin

Velocity

The loader plugin

VelocityContext: the proxy and its event manager

Plain Java

Your own application, embedding the loader

ModuleContext

"Plain Java" means exactly that: a Spring Boot service, a CLI tool, a desktop app, a daemon. Anything with a JVM can host a protected module.

1. Write the module

Implement ObsidianModule and set the Obsidian-Module manifest attribute to your entry class. Your class is not a Bukkit or Velocity plugin, so it has no plugin.yml and no @Plugin annotation.

public final class MyModule implements ObsidianModule {

    private ModuleContext ctx;

    @Override
    public void onEnable(ModuleContext context) {
        this.ctx = context;
        ctx.logger().info("Enabled on " + ctx.platform());

        // Tier and entitlements are identical on every host: they came out of the signed envelope.
        long maxHomes = ctx.entitlementLong("max_homes", 3);
        if (ctx.hasEntitlement("addons")) { /* a paid feature */ }
    }

    @Override
    public void onDisable() { }
}

Get the interfaces from the public API repository:

Using platform features

ModuleContext mentions no platform types, which is what makes one jar run everywhere. When you need Bukkit or Velocity, take them off the context by pattern matching:

Registrations made through the context are torn down for you on disable. Do not keep static references to module classes: the module runs under a throwaway classloader, and a static reference pins the old one across a reload.

2. Create the product and upload

In the dashboard, create a Product, then upload the compiled jar. The platform generates an AES-256 key, encrypts the jar at rest, and discards the plaintext. Every upload is retained as a version, so you can roll back or run a beta channel.

A product can hold several jars under one license, for instance a lobby plugin and a game plugin.

3. Issue a license

Create a License for the product: expiry, seat limit, and any tier or entitlements. Issue keys manually, in bulk, or automatically from a marketplace purchase.

4. Ship the loader

Paper

Download the generic loader jar from your dashboard, or build your own. Ship it with a config.yml:

Velocity

The same, with the Velocity loader jar and plugins/obsidianloader/config.properties (a loader you build yourself uses your own plugin id and data directory):

Velocity cannot disable a plugin at runtime, so on a failed activation the loader registers nothing and logs why. The module never runs either way.

Plain Java

There is no loader plugin to ship: you embed the loader in your own application.

See StandaloneHost.java.

What happens on activation

  1. The loader collects a machine fingerprint plus a fresh random nonce.

  2. It POSTs to /api/v1/activate with an X-Signature header, base64(HMAC-SHA256(key = licenseKey, msg = raw body)), proving it holds the key and pinning the exact bytes.

  3. The server checks the key: exists, matches the product, not revoked, not expired, a seat is available for this machine, not throttled, IP not banned. Every attempt is logged either way.

  4. On success it returns a signed envelope: the AES-256-GCM encrypted jar, the AES key, the version, the signed ent block (tier and entitlements), and an Ed25519 signature over nonce.version.payload.key.ent.

  5. The loader verifies the signature against the pinned serverPublicKey, checks the nonce is the one it sent, decrypts in memory, and hands the bytes to a custom class loader.

  6. It reads the Obsidian-Module manifest attribute, instantiates that class through its public no-arg constructor, and calls onEnable.

Because the envelope is signed, a rogue server cannot substitute a malicious payload, and tier and entitlements cannot be edited on the wire.

Fail closed

No host runs your module if the config is missing or invalid, or if the license server is unreachable. This is deliberate: strict online, no offline grace period. Tell your customers, because a server with no outbound network is a support ticket waiting to happen.

Upgrading an older module

ProtectedModule, whose onEnable takes a PluginContext, still works. Existing modules keep running with no recompile: the loader detects them and drives them exactly as before.

They are Paper-only, though. To run on Velocity or standalone, switch to ObsidianModule:

then move any Bukkit code behind instanceof PluginContext, in its own class.

Honest limits

This raises the bar a long way but is not unbreakable. The JVM belongs to the attacker: a determined party can attach a Java agent or dump the heap and recover the decrypted bytes at runtime. What you durably get is no plaintext jar on disk, a server-side kill switch, per-machine binding, and a full access log. Obfuscating the jar before upload is a worthwhile complement.

Last updated