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

332 lines
14 KiB
Markdown

# 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](#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](#url-encryption-aes-256-gcm). |
| 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
```
```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)
```json
{
"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](#dynamic-url-delivery). When **Encrypt target URL** is also enabled, this field contains the AES-256-GCM ciphertext (see [URL Encryption](#url-encryption-aes-256-gcm)). 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
```bash
# 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)
```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
```bash
# 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/`](../../examples/remote-activation-worker/README.md):
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)
```js
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.