๐ก๏ธ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.
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:
Keep platform code in its own class. A class is only loaded the first time it is used, so as long as your org.bukkit imports live in a separate class behind that instanceof, the same jar loads cleanly on Velocity and in a plain Java process. Put the Bukkit imports in the module class itself and it stops being portable. See PortableModule.java and PaperSide.java.
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
Ship your own loader, not the generic one. A loader hosts exactly one module, and a server refuses two plugins with the same name, so a customer who bought protected plugins from two sellers could only install one generic ObsidianLoader. Building your own is about ten lines: see Ship your own loader. The dashboard loader below is for testing and for customers who run only one protected plugin.
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
The loader collects a machine fingerprint plus a fresh random nonce.
It POSTs to
/api/v1/activatewith anX-Signatureheader,base64(HMAC-SHA256(key = licenseKey, msg = raw body)), proving it holds the key and pinning the exact bytes.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.
On success it returns a signed envelope: the AES-256-GCM encrypted jar, the AES key, the version, the signed
entblock (tier and entitlements), and an Ed25519 signature overnonce.version.payload.key.ent.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.It reads the
Obsidian-Modulemanifest attribute, instantiates that class through its public no-arg constructor, and callsonEnable.
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.
When the platform is updated, download and redistribute the latest loader. An older loader that computes a different signed string fails closed with "response signature invalid".
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