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
- 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)
332 lines
14 KiB
Markdown
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.
|