Skip to content

Commit 82490f9

Browse files
authored
fix(pro): enforce signed license heartbeat lifecycle (#312)
* fix(pro): enforce signed license heartbeat lifecycle * fix(pro): fail heartbeat timeout promptly on Python 3.10
1 parent cc3e758 commit 82490f9

21 files changed

Lines changed: 2056 additions & 620 deletions

‎infra/license-worker/README.md‎

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ Pairs with the Phase 1 telemetry worker at `infra/telemetry-worker/` (different
1313
| `/admin/issue` | POST | bearer | Sign + store new license, return activation code |
1414
| `/admin/revoke` | POST | bearer | Revoke a license_id |
1515
| `/admin/list` | GET | bearer | List all licenses with status + heartbeat info |
16+
| `/admin/signer` | GET | bearer | Cryptographically verify `SIGNING_KEY_ID` matches `PRIVATE_KEY` |
1617
| `/v1/pubkey` | GET | none | Return current Ed25519 public key (for offline verify) |
17-
| `/v1/activation/:code` | GET | none | Fetch signed license by one-time code (24h TTL, single-use) |
18-
| `/v1/heartbeat` | POST | none | Record heartbeat + check revocation/expiry |
18+
| `/v1/activation/:code` | POST | one-time code | Bind hardware and fetch signed v2 license (24h TTL, single-use) |
19+
| `/v1/heartbeat` | POST | nonce-bound | Record heartbeat + return signed status attestation |
1920
| `/v1/revocation/:license_id` | GET | none | Check if a license is revoked |
2021

2122
## Deploy (one-time setup, ~25 min)
@@ -53,9 +54,10 @@ curl -X POST https://reflex-licenses.<subdomain>.workers.dev/admin/init \
5354
# IMMEDIATELY:
5455
# a. Copy the private_key_b64 and set it as a Worker Secret:
5556
# echo '<private_key_b64>' | wrangler secret put PRIVATE_KEY
56-
# b. Copy the public_key_b64 into src/tether/pro/_public_key.py
57-
# (replace the BUNDLED_PUBLIC_KEY_B64 constant)
58-
# c. Discard the private_key_b64 from your terminal scrollback (it never
57+
# b. Bind the private key to the exact D1 public-key row:
58+
# echo '<key_id>' | wrangler secret put SIGNING_KEY_ID
59+
# c. Add key_id → public_key_b64 to TRUSTED_PUBLIC_KEYS_B64
60+
# d. Discard the private_key_b64 from your terminal scrollback (it never
5961
# needs to leave wrangler again)
6062
#
6163
# 9. Update src/tether/pro/license.py:DEFAULT_LICENSE_ENDPOINT to your worker URL
@@ -66,6 +68,30 @@ curl https://reflex-licenses.<subdomain>.workers.dev/healthz
6668
curl https://reflex-licenses.<subdomain>.workers.dev/v1/pubkey
6769
```
6870

71+
### Existing-install migration
72+
73+
Older installs may already have one active D1 public-key row and a matching
74+
`PRIVATE_KEY`, but no `SIGNING_KEY_ID`. Migrate that install before deploying
75+
the stricter Worker:
76+
77+
1. Query the active rows directly with `wrangler d1 execute reflex-licenses
78+
--remote --json --command "SELECT key_id, public_key_b64 FROM master_keys
79+
WHERE retired_at IS NULL"`.
80+
2. Compare the row's public key with the key already shipped in
81+
`TRUSTED_PUBLIC_KEYS_B64`. If more than one row is active, do not guess: set
82+
`TETHER_SIGNING_KEY_ID` to the row known to match the existing private key.
83+
3. Set that id with `wrangler secret put SIGNING_KEY_ID`, then deploy.
84+
4. Call authenticated `GET /admin/signer`. A 200 response with
85+
`{ "verified": true, "key_id": "..." }` proves the configured private key
86+
matches the selected non-retired D1 row. Any mismatch fails closed.
87+
88+
`deploy.sh` performs these steps automatically for the normal one-active-key
89+
case. It refuses an ambiguous multi-key migration or an active public row with
90+
no `PRIVATE_KEY`; it never creates a replacement private key for an existing
91+
public row. Set `REFLEX_ADMIN_TOKEN` before running it so the script can verify
92+
the coupled signer immediately after deployment; it refuses to migrate an
93+
existing signer without that verification credential.
94+
6995
## Issue your first license
7096

7197
```bash
@@ -106,7 +132,18 @@ python -m tether.admin.revoke_license \
106132
--reason "Refund processed"
107133
```
108134

109-
Customer's running deployment will fail its next heartbeat (within 24h) and refuse to serve.
135+
Customer's running deployment will fail its next heartbeat and refuse to serve.
136+
137+
## Signed heartbeat contract
138+
139+
The client sends a fresh unpadded-base64url 16-byte `request_nonce`. The Worker
140+
returns an Ed25519-signed `tether.license.heartbeat` v1 attestation containing
141+
the echoed nonce, `license_id`, active signing `key_id`, Unix `issued_at`,
142+
`valid_until`, and status (`active`, `expired`, or `revoked`; clients also
143+
understand `suspended`). Active validity is capped at 24 hours and at the
144+
license expiry. Released clients accept five minutes of clock skew, persist
145+
only verified attestations, and fail paid requests closed when the signed
146+
deadline plus skew elapses.
110147

111148
## Privacy posture
112149

@@ -142,14 +179,22 @@ WHERE l.revoked_at IS NULL
142179
);
143180
```
144181

145-
## Key rotation (Phase 2 — not implemented yet)
182+
## Key rotation
146183

147184
The schema supports key rotation via the `master_keys.retired_at` column, but the rotation endpoint (`POST /admin/rotate`) isn't built yet. When you need it:
148185

149-
1. Generate a new Ed25519 keypair (new POST /admin/init variant)
150-
2. New licenses get signed with the new key
151-
3. Old key stays valid for verification (grace period)
152-
4. Customer-side bundled key gets a list of N trusted keys instead of one
153-
5. Eventually retire the old key when no licenses signed with it remain
186+
1. Generate the new Ed25519 pair through an audited rotation procedure and
187+
insert its public half as a new non-retired `master_keys` row. Keep the old
188+
row active.
189+
2. Ship both old and new public keys in `TRUSTED_PUBLIC_KEYS_B64` and release
190+
that client trust overlap before changing the signer.
191+
3. Set `PRIVATE_KEY` and `SIGNING_KEY_ID` to the new coupled pair in one
192+
controlled deployment window, then require authenticated `/admin/signer`
193+
to return the new id with `verified: true`. Roll back both secrets together
194+
if verification fails.
195+
4. Keep the old public key trusted throughout the maximum license/attestation
196+
overlap.
197+
5. Retire the old D1 row and remove its client trust only after that overlap
198+
has elapsed.
154199

155200
Plan to revisit when you have ~50 active licenses or a security incident requires rotation.

‎infra/license-worker/deploy.sh‎

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
# 5. Applies the schema
1616
# 6. Generates a strong ADMIN_TOKEN, prints it ONCE, then sets it as a Worker Secret
1717
# 7. Optionally sets SLACK_WEBHOOK_URL
18-
# 8. Deploys the worker
19-
# 9. Calls /admin/init to generate the Ed25519 keypair
20-
# 10. Sets PRIVATE_KEY as a Worker Secret
21-
# 11. Prints the public_key_b64 + key_id you need to paste into
18+
# 8. Migrates an existing active signer id before deploying, when present
19+
# 9. Deploys the worker
20+
# 10. Calls /admin/init for a fresh install, or verifies the existing signer
21+
# 11. Sets PRIVATE_KEY + SIGNING_KEY_ID for a fresh install
22+
# 12. Prints the public_key_b64 + key_id you need to paste into
2223
# src/reflex/pro/_public_key.py + the worker URL for src/reflex/pro/activate.py
2324
#
2425
# After this script: edit those two Python constants, commit + push, and your
@@ -36,6 +37,34 @@ warn() { printf "${YELLOW}⚠${NC} %s\n" "$*"; }
3637
err() { printf "${RED}✗${NC} %s\n" "$*" >&2; }
3738
info() { printf "${CYAN}→${NC} %s\n" "$*"; }
3839

40+
select_active_signing_key() {
41+
python3 -c '
42+
import json, os, sys
43+
value = json.load(sys.stdin)
44+
batches = value if isinstance(value, list) else [value]
45+
rows = [row for batch in batches for row in batch.get("results", [])]
46+
requested = os.environ.get("TETHER_SIGNING_KEY_ID", "")
47+
if requested:
48+
rows = [row for row in rows if row.get("key_id") == requested]
49+
if len(rows) != 1:
50+
print(f"TETHER_SIGNING_KEY_ID={requested!r} is not an active D1 key", file=sys.stderr)
51+
raise SystemExit(4)
52+
elif not rows:
53+
raise SystemExit(3)
54+
elif len(rows) != 1:
55+
print("multiple active D1 keys; set TETHER_SIGNING_KEY_ID to the key matching PRIVATE_KEY", file=sys.stderr)
56+
raise SystemExit(4)
57+
row = rows[0]
58+
print(f"{row['"'"'key_id'"'"']}\t{row['"'"'public_key_b64'"'"']}")
59+
'
60+
}
61+
62+
# Pure selector mode exercises the exact branch logic without Cloudflare access.
63+
if [ "${1:-}" = "--select-active-key" ]; then
64+
select_active_signing_key
65+
exit $?
66+
fi
67+
3968
cd "$(dirname "$0")"
4069

4170
# ─── 1. wrangler install ─────────────────────────────────────────────────────
@@ -129,7 +158,38 @@ else
129158
fi
130159
fi
131160

132-
# ─── 8. Deploy the worker ────────────────────────────────────────────────────
161+
# ─── 8. Pre-bind an existing signer before deploying the stricter Worker ────
162+
ACTIVE_KEYS_JSON=$(wrangler d1 execute "$DB_NAME" --remote --json --command \
163+
"SELECT key_id, public_key_b64 FROM master_keys WHERE retired_at IS NULL ORDER BY generated_at DESC")
164+
set +e
165+
ACTIVE_KEY_ROW=$(printf '%s' "$ACTIVE_KEYS_JSON" | select_active_signing_key)
166+
KEY_SELECT_STATUS=$?
167+
set -e
168+
if [ "$KEY_SELECT_STATUS" -eq 0 ]; then
169+
IFS=$'\t' read -r KEY_ID PUBKEY_B64 <<< "$ACTIVE_KEY_ROW"
170+
if [ -z "${ADMIN_TOKEN:-}" ]; then
171+
if [ -n "${REFLEX_ADMIN_TOKEN:-}" ]; then
172+
ADMIN_TOKEN="$REFLEX_ADMIN_TOKEN"
173+
else
174+
err "Existing-signer migration requires REFLEX_ADMIN_TOKEN so /admin/signer can verify the binding immediately after deploy. SIGNING_KEY_ID and Worker code were not changed."
175+
exit 1
176+
fi
177+
fi
178+
if ! wrangler secret list 2>/dev/null | grep -q '"PRIVATE_KEY"'; then
179+
err "D1 has active key ${KEY_ID}, but PRIVATE_KEY is missing. Restore the matching private key; do not generate a replacement for the existing public row."
180+
exit 1
181+
fi
182+
info "Binding existing active D1 key ${KEY_ID} before deploying signer enforcement..."
183+
echo -n "$KEY_ID" | wrangler secret put SIGNING_KEY_ID
184+
ok "Existing SIGNING_KEY_ID set before deploy"
185+
elif [ "$KEY_SELECT_STATUS" -eq 3 ]; then
186+
info "No active D1 signing key; fresh install will initialize one after deploy"
187+
else
188+
err "Could not select the existing signing key safely. If rotation overlap leaves multiple active rows, set TETHER_SIGNING_KEY_ID to the row that matches PRIVATE_KEY."
189+
exit 1
190+
fi
191+
192+
# ─── 9. Deploy the worker ────────────────────────────────────────────────────
133193
info "Deploying worker..."
134194
DEPLOY_OUT=$(wrangler deploy 2>&1)
135195
echo "$DEPLOY_OUT" | tail -10
@@ -140,7 +200,7 @@ if [ -z "$WORKER_URL" ]; then
140200
fi
141201
ok "Worker deployed at: ${WORKER_URL}"
142202

143-
# ─── 9. /admin/init ──────────────────────────────────────────────────────────
203+
# ─── 10. /admin/init ─────────────────────────────────────────────────────────
144204
# Skip init if we don't have the ADMIN_TOKEN locally (it was already set as a
145205
# Secret on a prior run and we don't have the value anymore).
146206
if [ -z "${ADMIN_TOKEN:-}" ]; then
@@ -173,20 +233,36 @@ elif echo "$INIT_OUT" | grep -q "keypair_generated"; then
173233
KEY_ID=$(echo "$INIT_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['key_id'])")
174234
ok "Keypair generated (id=${KEY_ID})"
175235

176-
# ─── 10. Set PRIVATE_KEY ──────────────────────────────────────────────────
236+
# ─── 11. Set PRIVATE_KEY + SIGNING_KEY_ID ─────────────────────────────────
177237
info "Setting PRIVATE_KEY as Worker Secret (this is the only place it goes — never persisted elsewhere)..."
178238
echo -n "$PRIVKEY_B64" | wrangler secret put PRIVATE_KEY
179239
ok "PRIVATE_KEY set as Worker Secret"
240+
echo -n "$KEY_ID" | wrangler secret put SIGNING_KEY_ID
241+
ok "SIGNING_KEY_ID set to ${KEY_ID}"
180242
unset PRIVKEY_B64
181243
else
182244
err "Unexpected response from /admin/init:"
183245
echo "$INIT_OUT"
184246
exit 1
185247
fi
186248

187-
# ─── 11. Print next-steps with values to paste ───────────────────────────────
188-
PUBKEY_FILE="../../src/reflex/pro/_public_key.py"
189-
ACTIVATE_FILE="../../src/reflex/pro/activate.py"
249+
# This authenticated check invokes loadSigner(), which signs and verifies a
250+
# fixed challenge. Matching ids alone do not prove PRIVATE_KEY is the private
251+
# half of the selected D1 public key.
252+
info "Verifying configured signer/private-key binding..."
253+
SIGNER_OUT=$(curl -sS "${WORKER_URL}/admin/signer" \
254+
-H "Authorization: Bearer ${ADMIN_TOKEN}")
255+
VERIFIED_KEY_ID=$(printf '%s' "$SIGNER_OUT" | python3 -c \
256+
"import sys,json; value=json.load(sys.stdin); print(value.get('key_id', '')) if value.get('verified') is True else raise SystemExit(1)")
257+
if [ "$VERIFIED_KEY_ID" != "$KEY_ID" ]; then
258+
err "Signer verification failed for ${KEY_ID}: ${SIGNER_OUT}"
259+
exit 1
260+
fi
261+
ok "Signer binding cryptographically verified (id=${VERIFIED_KEY_ID})"
262+
263+
# ─── 12. Print next-steps with values to paste ───────────────────────────────
264+
PUBKEY_FILE="../../src/tether/pro/_public_key.py"
265+
ACTIVATE_FILE="../../src/tether/pro/activate.py"
190266

191267
printf "\n\n${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
192268
printf "${GREEN}DEPLOY COMPLETE${NC}\n"
@@ -199,8 +275,7 @@ printf "${YELLOW}Public key:${NC} ${PUBKEY_B64}\n\n"
199275
printf "${CYAN}Next steps (3 manual edits, ~2 min):${NC}\n\n"
200276

201277
printf "1. Update ${PUBKEY_FILE}:\n"
202-
printf " BUNDLED_PUBLIC_KEY_B64 = \"${PUBKEY_B64}\"\n"
203-
printf " BUNDLED_KEY_ID = \"${KEY_ID}\"\n\n"
278+
printf " Add \"${KEY_ID}\": \"${PUBKEY_B64}\" to TRUSTED_PUBLIC_KEYS_B64\n\n"
204279

205280
printf "2. Update ${ACTIVATE_FILE}:\n"
206281
printf " DEFAULT_LICENSE_ENDPOINT = \"${WORKER_URL}\"\n\n"

‎infra/license-worker/package.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"private": true,
3+
"type": "module",
4+
"scripts": {
5+
"test": "node --test test/*.test.js"
6+
}
7+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import assert from "node:assert/strict";
2+
import { spawnSync } from "node:child_process";
3+
import { readFileSync } from "node:fs";
4+
import test from "node:test";
5+
6+
function select(results, env = {}) {
7+
return spawnSync("bash", ["deploy.sh", "--select-active-key"], {
8+
cwd: new URL("..", import.meta.url),
9+
env: { ...process.env, TETHER_SIGNING_KEY_ID: "", ...env },
10+
input: JSON.stringify([{ results, success: true }]),
11+
encoding: "utf8",
12+
});
13+
}
14+
15+
test("deploy helper distinguishes fresh install from one existing active key", () => {
16+
const fresh = select([]);
17+
assert.equal(fresh.status, 3);
18+
assert.equal(fresh.stdout, "");
19+
20+
const existing = select([{ key_id: "key_existing", public_key_b64: "cHVi" }]);
21+
assert.equal(existing.status, 0, existing.stderr);
22+
assert.equal(existing.stdout.trim(), "key_existing\tcHVi");
23+
});
24+
25+
test("deploy helper refuses ambiguous rotation rows unless id is explicit", () => {
26+
const rows = [
27+
{ key_id: "key_old", public_key_b64: "b2xk" },
28+
{ key_id: "key_new", public_key_b64: "bmV3" },
29+
];
30+
const ambiguous = select(rows);
31+
assert.equal(ambiguous.status, 4);
32+
assert.match(ambiguous.stderr, /multiple active D1 keys/);
33+
34+
const selected = select(rows, { TETHER_SIGNING_KEY_ID: "key_new" });
35+
assert.equal(selected.status, 0, selected.stderr);
36+
assert.equal(selected.stdout.trim(), "key_new\tbmV3");
37+
});
38+
39+
test("existing signer pre-bind precedes deploy and fresh signer setup is verified", () => {
40+
const script = readFileSync(new URL("../deploy.sh", import.meta.url), "utf8");
41+
const deploy = script.indexOf('info "Deploying worker..."');
42+
const prebind = script.indexOf('echo -n "$KEY_ID" | wrangler secret put SIGNING_KEY_ID');
43+
const freshBind = script.lastIndexOf('echo -n "$KEY_ID" | wrangler secret put SIGNING_KEY_ID');
44+
const verify = script.indexOf('${WORKER_URL}/admin/signer');
45+
assert.ok(prebind !== -1 && prebind < deploy);
46+
assert.ok(freshBind > deploy);
47+
assert.ok(verify > freshBind);
48+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import worker from "../worker.js";
2+
3+
function b64(buffer) {
4+
return Buffer.from(buffer).toString("base64");
5+
}
6+
7+
const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
8+
const privatePkcs8 = await crypto.subtle.exportKey("pkcs8", pair.privateKey);
9+
const publicRaw = await crypto.subtle.exportKey("raw", pair.publicKey);
10+
const expiresAt = new Date(Date.now() + 86400000).toISOString();
11+
const DB = {
12+
prepare(sql) {
13+
return {
14+
bind() { return this; },
15+
async first() {
16+
if (sql.includes("FROM activation_codes")) {
17+
return { license_id: "lic_unicode_live", expires_at: expiresAt, used: 0 };
18+
}
19+
if (sql.includes("SELECT license_json")) {
20+
return { license_json: JSON.stringify({
21+
license_version: 2,
22+
license_id: "lic_unicode_live",
23+
customer_id: "客户 É",
24+
tier: "pro",
25+
issued_at: new Date().toISOString(),
26+
expires_at: expiresAt,
27+
max_seats: 1,
28+
hardware_binding: null,
29+
}) };
30+
}
31+
if (sql.includes("FROM master_keys")) {
32+
return { public_key_b64: b64(publicRaw) };
33+
}
34+
return null;
35+
},
36+
async run() { return { success: true, meta: { changes: 1 } }; },
37+
};
38+
},
39+
};
40+
const env = {
41+
DB,
42+
PRIVATE_KEY: b64(privatePkcs8),
43+
SIGNING_KEY_ID: "key_unicode_live",
44+
};
45+
const response = await worker.fetch(new Request(
46+
"https://worker.test/v1/activation/REFLEX-AAAA-BBBB-CCCC",
47+
{
48+
method: "POST",
49+
headers: { "content-type": "application/json" },
50+
body: JSON.stringify({
51+
hardware_binding: {
52+
gpu_uuid: "GPU-一",
53+
gpu_name: "Éclair GPU",
54+
cpu_count: 8,
55+
},
56+
}),
57+
},
58+
), env);
59+
if (response.status !== 200) throw new Error(await response.text());
60+
process.stdout.write(JSON.stringify({
61+
public_key_b64: b64(publicRaw),
62+
response: await response.json(),
63+
}));

0 commit comments

Comments
 (0)