> For the complete documentation index, see [llms.txt](https://docs.ohalee.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ohalee.com/products/obsidian-license/protected-plugins.md).

# 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](/products/obsidian-license/license-check.md) 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.

```java
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:

```kotlin
repositories { maven("https://jitpack.io") }
dependencies { compileOnly("com.github.ohAleee.obsidianlicense-api:api:v1.2.1") }
```

{% embed url="<https://github.com/ohAleee/obsidianlicense-api>" %}

### 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:

```java
if (context instanceof PluginContext paper) {
    paper.registerListener(new MyListener());
    paper.registerCommand("mycmd", "Does the thing", new MyCommand());
}
```

{% hint style="warning" %}
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](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/PortableModule.java) and [PaperSide.java](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/PaperSide.java).
{% endhint %}

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](https://license.ohalee.com), 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](/products/obsidian-license/multiple-jars.md) 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](/products/obsidian-license/tiers-and-entitlements.md). Issue keys manually, in bulk, or automatically from a marketplace purchase.

## 4. Ship the loader

{% hint style="warning" %}
**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](/products/obsidian-license/your-own-loader.md). The dashboard loader below is for testing and for customers who run only one protected plugin.
{% endhint %}

### Paper

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

```yaml
serverUrl: "https://license.ohalee.com"
licenseKey: "OBS-XXXX-XXXX-XXXX-XXXX"
productId: "my-plugin"
serverPublicKey: "PASTE_THE_KEY_FROM_YOUR_DASHBOARD"
# Only for a product that ships several jars:
# variant: "game"
```

### 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):

```properties
serverUrl=https://license.ohalee.com
licenseKey=OBS-XXXX-XXXX-XXXX-XXXX
productId=my-plugin
serverPublicKey=PASTE_THE_KEY_FROM_YOUR_DASHBOARD
# variant=lobby
```

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.

```java
ObsidianLoader loader = ObsidianLoader.builder()
        .serverUrl("https://license.ohalee.com")
        .productId("my-service")
        .licenseKey(System.getenv("LICENSE_KEY"))
        .serverPublicKey("MCowBQYDK2VwAyEA...")
        .dataFolder(Path.of("data"))
        // .variant("worker")     only for a product that ships several jars
        .build();

// Fail closed: this throws on any denial, and the module never runs.
ObsidianLoader.LoadedLicense license = loader.start();

Runtime.getRuntime().addShutdownHook(new Thread(loader::stop));
```

See [StandaloneHost.java](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/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.

{% hint style="warning" %}
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".
{% endhint %}

## 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`:

```java
-public final class MyModule implements ProtectedModule {
-    public void onEnable(PluginContext ctx) {
+public final class MyModule implements ObsidianModule {
+    public void onEnable(ModuleContext ctx) {
```

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.
