drukscode-v2/.github/docs/remote-activation.md
OpenCode AI e9899d2e6d
Some checks failed
Android CI Build / check (push) Has been cancelled
Android CI Build / package-apks (push) Has been cancelled
Docs Deploy / build (push) Has been cancelled
Module Market Check / Validate modules/ (push) Has been cancelled
Module Market Publish / Generate submissions.json (push) Has been cancelled
Docs Deploy / deploy (push) Has been cancelled
Initial DruksCode V2: fresh fork of web-to-app (f3b488e) with DruksCode identity
- Package rename com.webtoapp -> br.com.drukstech.codeapp
- Product rename WebToApp/web-to-app -> DruksCode/drukscode
- Design system Wta* -> Dkc*
- applicationId br.com.drukstech.codeapp (.play for gplay flavor)
- rootProject.name DruksCode
- Fresh git history (upstream kept as remote for sync)
2026-09-12 02:42:02 +02:00

14 KiB

Remote Activation — Server Contract

DruksCode can verify activation codes against your own HTTPS endpoint instead of (or in addition to) the codes baked into the APK. This lets you revoke codes, issue them dynamically, and control usage centrally — without rebuilding the app.

Security note. The host is open source and verification runs on the client, so a determined attacker can still patch the check out. Remote verification raises the bar and gives you revocation/rotation; it is not an anti-cracking guarantee. The response signature below stops a fake server or a man-in-the-middle from forging an ok: true, which is the part that matters most.

How it is configured in the app

When building an app (or in App Modifier), enable Online Verification under the activation-code section and fill in:

Field Meaning
Verification endpoint Your https://… URL. Plain http:// is rejected.
Signature public key An EC P-256 public key in Base64 (SPKI / BEGIN PUBLIC KEY). The app verifies every response with it.
Offline policy ALLOW_CACHED (default): allow the last successful result until it expires. DENY: block when offline. ALLOW: always allow when offline (insecure).
Deliver target URL When on, the target URL is delivered by the server at activation time (not packaged in the APK). See Dynamic URL delivery.
Encrypt target URL When on (requires Deliver URL), the server must encrypt the URL with AES-256-GCM before signing. See URL Encryption.
AES-256 key 32-byte Base64 key shared with the server. Required when encryption is enabled.

Request (app → your server)

POST <your verification endpoint>
Content-Type: application/json; charset=utf-8
Accept: application/json
{
  "code": "ABC123",
  "deviceId": "f3a9…",
  "packageName": "com.example.app",
  "nonce": "Base64-random-24-bytes",
  "ts": 1733270400000,
  "deviceBound": false
}
  • code is normalised (uppercased, trimmed) before sending.

  • deviceId is a per-device identifier (not a hardware ID; it is a salted hash).

  • nonce is fresh per request — you must echo it back unchanged (replay protection).

  • ts is the client clock in epoch milliseconds.

  • deviceBound is true when the app has the Device binding (one-time codes) toggle enabled in the editor's remote-activation section (it is always false for purely local verification — see below). For such codes your server should enforce per-device binding: record the first deviceId that activates a given code, and reject the same code from any different deviceId (return { "ok": false }). This is the only way to truly restrict a device-bound code to one device — the app itself has no shared state between devices, so a purely local device-bound code can only prevent re-activation on the same device after a local reset or hardware change.

    The reference worker in examples/remote-activation-worker implements this as seats: maxDevices (default 1) devices may claim a code; a code with maxDevices: 1 behaves as a one-time / single-device code. The claimed device id survives uninstall + reinstall on the same device (it derives from ANDROID_ID, stable per signing key), so a seat is only released when you edit the KV record. To free a seat manually, remove the deviceId entry from record.devices (or delete code:<CODE>).

Response (your server → app)

{
  "ok": true,
  "expiresAt": 1735862400000,
  "remainingUses": 5,
  "message": "",
  "nonce": "Base64-random-24-bytes",
  "sig": "Base64-ECDSA-signature",
  "url": "https://example.com/app"
}
Field Type Notes
ok boolean true grants activation.
expiresAt number | null Epoch ms. If present and in the past, the app treats it as expired. Use 0/omit for "never expires".
remainingUses number | null Informational. Use -1/omit when not tracking.
message string Shown to the user on rejection.
nonce string Must equal the request nonce.
sig string Base64 ECDSA (SHA256withECDSA, DER-encoded) over the canonical payload below.
url string | null Optional. The target URL to load. Only used (and signature-bound) when the app has Deliver target URL from server enabled — see Dynamic URL delivery. When Encrypt target URL is also enabled, this field contains the AES-256-GCM ciphertext (see URL Encryption). Omit otherwise.

Canonical payload that gets signed

The app rebuilds this exact JSON string and verifies sig against it. Key order and formatting matter — it is compact JSON with these four keys, in this order:

{"ok":<true|false>,"expiresAt":<number>,"remainingUses":<number>,"nonce":"<nonce>"}
  • expiresAt falls back to 0 when you omit it.
  • remainingUses falls back to -1 when you omit it.
  • No spaces, booleans unquoted, numbers unquoted.

When Deliver target URL from server is enabled, a fifth key url is appended (after nonce), and the signature must cover it too:

{"ok":<true|false>,"expiresAt":<number>,"remainingUses":<number>,"nonce":"<nonce>","url":"<url>"}

url falls back to "" when you omit it. Apps that do not enable URL delivery verify the legacy four-key payload, so existing servers keep working unchanged.

Example of the precise bytes to sign:

{"ok":true,"expiresAt":1735862400000,"remainingUses":5,"nonce":"k7Q…"}

Dynamic URL delivery

When Deliver target URL from server is enabled in the app, the target URL is not packaged into the APK. Instead the app loads the URL you return in the url field after a successful activation (and caches it for offline launches under ALLOW_CACHED). This lets you change the URL without repackaging, and keeps the URL out of the APK so it cannot be extracted statically.

  • Return the URL in url and include it in the signed payload (fifth key).
  • The URL is cached on the device after a successful online activation; offline launches reuse the cached URL per the offline policy.
  • The URL is delivered in the clear inside the signed response (over HTTPS). This raises the bar against casual extraction but is not DRM-grade — a determined attacker can still recover it from the response or memory.

URL Encryption (AES-256-GCM)

When both Deliver target URL from server and Encrypt target URL are enabled, the server must encrypt the URL with AES-256-GCM before signing. The ciphertext goes into the url field and the signed payload.

Parameter Value
Algorithm AES-256-GCM (authenticated encryption)
Key 32 bytes, shared between app config and server
IV 12 random bytes per encryption
Wire format Base64(IV[12B] || ciphertext || GCM tag[16B])

Why encrypt the URL when the response is already over HTTPS and ECDSA-signed? Defense in depth. If an attacker compromises the TLS layer (corporate MITM proxy with a user-installed CA, misconfigured certificate pinning bypass) or dumps the response from memory, the URL stays unreadable without the AES key. The signature still prevents tampering, and the encryption prevents reading.

Generating an AES-256 key

# Generate a 32-byte random key and Base64-encode it
openssl rand -base64 32

Paste the same Base64 string into both the app's AES-256 key field and your server configuration.

Server-side encryption (Node.js)

const crypto = require("crypto");

function encryptUrl(plaintext, aesKeyBase64) {
  const key = Buffer.from(aesKeyBase64, "base64"); // 32 bytes
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
  let encrypted = cipher.update(plaintext, "utf8");
  encrypted = Buffer.concat([encrypted, cipher.final()]);
  const tag = cipher.getAuthTag(); // 16 bytes
  // Wire format: IV || ciphertext || tag
  return Buffer.concat([iv, encrypted, tag]).toString("base64");
}

Signing flow with encryption

  1. Encrypt the URL → Base64 ciphertext blob.
  2. Build the canonical payload with the ciphertext blob as the url value.
  3. Sign the canonical payload with ECDSA.
  4. Return the ciphertext blob in the response url field and the signature in sig.

The client verifies the signature first (which covers the ciphertext), then decrypts url with the shared AES key to recover the plaintext URL. An attacker who tampers with the ciphertext breaks the signature; an attacker who reads the response sees only encrypted bytes.

Generating a key pair

# private key (keep on your server)
openssl ecparam -name prime256v1 -genkey -noout -out ec_private.pem
# public key (paste into the app's "Signature public key" field, header lines optional)
openssl ec -in ec_private.pem -pubout -out ec_public.pem

Ready-to-deploy option (Cloudflare Worker)

If you would rather not run a server at all, there is a complete reference worker in examples/remote-activation-worker/: codes held in Workers KV (revocable without rebuilding the APK), signed responses, device binding, optional AES-256-GCM URL delivery, and a GET / self-check that hands you the public key to paste into the app. It deploys with npx wrangler deploy and ships a test that verifies responses the way the client does.

One caveat it documents, because it applies to any server you write: the request does not say whether the app expects a delivered URL, so the server cannot discover it. A code that carries a url must be paired with an app that has "Deliver target URL" enabled, and a code without one with an app that has it disabled — otherwise the two sides sign different payloads and every activation fails.

The Node example below is the same contract for your own infrastructure.

Reference server (Node.js, no framework)

const http = require("http");
const crypto = require("crypto");
const fs = require("fs");

const privateKey = crypto.createPrivateKey(fs.readFileSync("ec_private.pem"));

// Your own source of truth. Revoke by removing/flipping entries here.
// `url` is optional: set it (and enable "Deliver target URL from server" in the
// app) to deliver the target URL dynamically instead of packaging it.
// When URL encryption is enabled, encrypt `url` with the shared AES key before
// returning it (see the `encryptUrl` helper below).
const CODES = {
  "ABC123": { expiresAt: 1735862400000, remainingUses: 5, url: "https://example.com/app" },
};

// ---- AES-256-GCM encryption (only needed when "Encrypt target URL" is on) ----
const AES_KEY_BASE64 = process.env.AES_KEY_BASE64 || ""; // same as in the app config
const AES_KEY = AES_KEY_BASE64 ? Buffer.from(AES_KEY_BASE64, "base64") : null;

function encryptUrl(plaintext) {
  if (!AES_KEY || AES_KEY.length !== 32) return plaintext; // passthrough if not configured
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv("aes-256-gcm", AES_KEY, iv);
  let encrypted = cipher.update(plaintext, "utf8");
  encrypted = Buffer.concat([encrypted, cipher.final()]);
  const tag = cipher.getAuthTag();
  return Buffer.concat([iv, encrypted, tag]).toString("base64");
}
// -------------------------------------------------------------------------

function signedPayload({ ok, expiresAt, remainingUses, nonce, url }) {
  // Must match the app's canonical order exactly.
  const payload = {
    ok,
    expiresAt: expiresAt ?? 0,
    remainingUses: remainingUses ?? -1,
    nonce,
  };
  // Include url only when delivering it; it becomes the fifth signed key.
  // When encryption is on, `url` is already the ciphertext blob here.
  if (url !== undefined) {
    payload.url = url ?? "";
  }
  return JSON.stringify(payload);
}

function sign(payloadString) {
  return crypto
    .sign("sha256", Buffer.from(payloadString, "utf8"), privateKey)
    .toString("base64");
}

http
  .createServer((req, res) => {
    let body = "";
    req.on("data", (c) => (body += c));
    req.on("end", () => {
      let parsed;
      try {
        parsed = JSON.parse(body);
      } catch {
        res.writeHead(400);
        return res.end();
      }

      const code = String(parsed.code || "").trim().toUpperCase();
      const nonce = String(parsed.nonce || "");
      const entry = CODES[code];

      const result = entry
        ? { ok: true, expiresAt: entry.expiresAt, remainingUses: entry.remainingUses, message: "" }
        : { ok: false, expiresAt: 0, remainingUses: -1, message: "Code not recognised" };

      // Encrypt the URL if AES key is configured; the ciphertext goes into
      // both the signed payload and the response.
      const urlPlain = entry && entry.url ? entry.url : undefined;
      const urlEnc = urlPlain ? encryptUrl(urlPlain) : undefined;

      const sig = sign(signedPayload({ ...result, nonce, url: urlEnc }));

      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ...result, nonce, sig, url: urlEnc }));
    });
  })
  .listen(8443);

Serve it over HTTPS (behind a reverse proxy with a TLS cert, or with https.createServer). The app refuses non-HTTPS endpoints.

Offline behaviour

After a successful online check, the app caches the result (bound to the code and its expiresAt). With ALLOW_CACHED, a later offline launch is allowed until that expiry, then it prompts again. With DENY, any offline launch is blocked. With ALLOW, offline launches always pass — only use this if losing the gate offline is acceptable.

Privacy

When online verification is on, the app sends the activation code and a device identifier to the endpoint you configure. Disclose this to your users.