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)
137 lines
4.9 KiB
Markdown
137 lines
4.9 KiB
Markdown
# Remote activation — reference verification server
|
|
|
|
A Cloudflare Worker that speaks the contract described in
|
|
[`.github/docs/remote-activation.md`](../../.github/docs/remote-activation.md).
|
|
It is the "I don't want to build a backend" answer to remote activation: copy,
|
|
paste, deploy.
|
|
|
|
What you get:
|
|
|
|
- Codes held in Workers KV, revocable and rotatable without rebuilding an APK
|
|
- Every response signed with **EC P-256 / SHA-256**, so a fake server or a
|
|
man-in-the-middle cannot forge an `ok: true`
|
|
- Nonce echo (replay protection) and device binding for one-seat codes
|
|
- Optional dynamic URL delivery, with or without AES-256-GCM encryption
|
|
|
|
## 1. Generate a signing key
|
|
|
|
The app verifies responses with the public half; the Worker signs with the
|
|
private half.
|
|
|
|
```bash
|
|
# private key, PKCS#8, single-line Base64 — this is the Worker's SIGNING_KEY
|
|
openssl ecparam -genkey -name prime256v1 -noout -out ec.pem
|
|
openssl pkcs8 -topk8 -nocrypt -in ec.pem -out ec-pkcs8.pem
|
|
openssl base64 -A -in ec-pkcs8.pem -out ec-pkcs8.b64
|
|
|
|
# public key, SPKI — this goes into the app's "Signature public key" field
|
|
openssl ec -in ec.pem -pubout -out ec-pub.pem
|
|
openssl base64 -A -in ec-pub.pem -out ec-pub.b64
|
|
```
|
|
|
|
Keep `ec.pem` and `ec-pkcs8.b64` out of git. The public key is not a secret.
|
|
|
|
## 2. Deploy
|
|
|
|
```bash
|
|
cd examples/remote-activation-worker
|
|
npm install
|
|
|
|
npx wrangler login
|
|
npm run kv:create # paste the printed id into wrangler.toml
|
|
|
|
npx wrangler secret put SIGNING_KEY < ec-pkcs8.b64
|
|
npx wrangler deploy
|
|
```
|
|
|
|
Then open the deployed URL in a browser. A healthy worker answers:
|
|
|
|
```json
|
|
{
|
|
"ok": true,
|
|
"publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…",
|
|
"keyFingerprint": "3f9a1c02…",
|
|
"urlEncryption": "disabled",
|
|
"problems": []
|
|
}
|
|
```
|
|
|
|
`publicKey` is the SPKI Base64 of your key — paste it straight into the app's
|
|
**Signature public key** field. If `problems` is not empty, the worker will not
|
|
verify anything and will say why.
|
|
|
|
## 3. Add codes
|
|
|
|
One KV entry per code. The key is `code:` followed by the uppercased code.
|
|
|
|
```bash
|
|
# unlimited activations
|
|
npx wrangler kv:key put --binding=ACTIVATION_CODES "code:DEMO1234" \
|
|
'{"note":"demo"}'
|
|
|
|
# expires at a fixed date (epoch millis)
|
|
npx wrangler kv:key put --binding=ACTIVATION_CODES "code:YEAR2027" \
|
|
'{"expiresAt":1798761600000,"maxDevices":1,"devices":[]}'
|
|
|
|
# locked to one package, delivering an encrypted target URL
|
|
npx wrangler kv:key put --binding=ACTIVATION_CODES "code:VIP0001" \
|
|
'{"packageName":"com.example.app","url":"https://example.com/target","encryptUrl":true}'
|
|
```
|
|
|
|
`encryptUrl` requires an AES key on the worker:
|
|
|
|
```bash
|
|
openssl rand -base64 32 | tr -d '\n' | xargs -0 npx wrangler secret put AES_KEY
|
|
```
|
|
|
|
### Code record fields
|
|
|
|
| Field | Type | Meaning |
|
|
| --- | --- | --- |
|
|
| `expiresAt` | number \| null | Absolute expiry, epoch millis. Omit for never. |
|
|
| `maxDevices` | number | Seats, enforced only when the app sends `deviceBound: true`. Default 1. |
|
|
| `devices` | string[] | Device ids that have redeemed this code. Maintained by the worker. |
|
|
| `packageName` | string \| null | If set, the code only works for that package. |
|
|
| `url` | string \| null | Enables URL delivery. See the caveat below. |
|
|
| `encryptUrl` | boolean | AES-256-GCM encrypt `url`. Requires `AES_KEY`. |
|
|
| `message` | string | Returned to the client on success. |
|
|
| `note` | string | Your own bookkeeping; never leaves the server. |
|
|
|
|
## Two things to know before you go live
|
|
|
|
**URL delivery is configured on both sides, and they must agree.** The request
|
|
the app sends does not say whether it expects a delivered URL, so the worker
|
|
cannot tell. The rule is therefore: a code with a `url` is for an app with
|
|
**Deliver target URL** enabled, and a code without one is for an app with it
|
|
disabled. Mismatched, the signature check fails on every activation, because the
|
|
app and the server disagree about whether the URL is part of the signed payload.
|
|
|
|
**Workers KV is eventually consistent and has no compare-and-swap.** Two devices
|
|
redeeming the last seat of a device-bound code in the same instant can both
|
|
succeed. For most apps this is an acceptable risk at this scale; if it is not for
|
|
yours, move the record store to D1 or a Durable Object — the signing path is
|
|
unchanged, only `get`/`put` differ.
|
|
|
|
## Verifying your changes
|
|
|
|
`test/protocol-check.mjs` drives the real worker with a mock KV and verifies each
|
|
response the way the Android client does — same payload construction, same
|
|
ECDSA P-256 check in P1363 form, same AES-GCM wire format.
|
|
|
|
```bash
|
|
npm run check
|
|
```
|
|
|
|
It is the fastest way to catch a change that would silently break every
|
|
installed app.
|
|
|
|
## Protocol details worth not breaking
|
|
|
|
Both live in `RemoteActivationVerifier.kt` on the app side, and both are
|
|
reproduced in `src/index.js`:
|
|
|
|
1. The signature field is **`sig`**, not `signature`.
|
|
2. The signed payload has a fixed key order — `ok`, `expiresAt`,
|
|
`remainingUses`, `nonce`, then `url` when URL delivery is on — with `null`
|
|
collapsed to `0` for `expiresAt` and `-1` for `remainingUses`.
|