Skip to content

Commit d322a1c

Browse files
author
akira
committed
Refactor signing mechanism: Introduce MASTER_SECRET for per-token signing, update documentation, and adjust UI for admin key usage
1 parent dabf2f8 commit d322a1c

8 files changed

Lines changed: 288 additions & 88 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@ name: Deploy Kanariya
22

33
on:
44
workflow_dispatch:
5-
inputs:
6-
update_secrets:
7-
description: "Update Worker secrets before deploy (uses repo secrets)"
8-
required: false
9-
default: "false"
105
push:
116
branches: ["main"]
127

@@ -58,7 +53,6 @@ jobs:
5853
exit "${missing}"
5954
6055
- name: Update Worker secrets
61-
if: github.event_name == 'workflow_dispatch' && github.event.inputs.update_secrets == 'true'
6256
env:
6357
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
6458
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

README.md

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,13 @@ Configure the following on the Worker:
170170
- Required only if `/admin/export` is enabled. If unset, `/admin/export` returns 403.
171171
- `ALLOW_PUBLIC_EXPORT` (non-secret, optional)
172172
- Set to `1` to allow `/admin/export` without `ADMIN_KEY` (overrides admin key). Not recommended for public use.
173-
- `SIGNING_SECRET` (**secret**, optional)
174-
- Required when `REQUIRE_SIGNATURE=1`. Used to sign canary URLs.
173+
- `MASTER_SECRET` (**secret**, recommended)
174+
- Master secret used to derive per-token signing keys.
175+
- Signing key derivation: `derivedKey = HMAC(MASTER_SECRET, "token:" + token)`
176+
- Recommended when you want different effective keys per token without storing them.
177+
- `SIGNING_SECRET` (**secret**, legacy)
178+
- Backward-compatible fallback if `MASTER_SECRET` is not set.
179+
- If `MASTER_SECRET` is not set, Kanariya will also treat `SIGNING_SECRET` as the master secret for derived signing (ops-friendly), while still accepting legacy signatures created directly with `SIGNING_SECRET`.
175180

176181
Optional:
177182

@@ -199,6 +204,8 @@ Optional:
199204
- Default: 0. Set to `1` to require signed canary URLs.
200205
- `SIGNATURE_WINDOW_SECONDS` (non-secret, optional)
201206
- Default: 300 (allowed clock skew for `ts`, set 0 to disable window check).
207+
- `ALLOW_PUBLIC_SIGN` (non-secret, optional)
208+
- Default: 0. If set to `1`, `/admin/sign` can be called without `ADMIN_KEY`.
202209

203210
## Signed canary URLs (optional)
204211

@@ -208,15 +215,40 @@ This is useful when you want to reduce random scanning noise (only URLs you gene
208215
1) Enable signature requirement:
209216

210217
- Set `REQUIRE_SIGNATURE=1`
211-
- Set the secret key:
212-
- `wrangler secret put SIGNING_SECRET`
218+
- Set the master secret key (recommended):
219+
- `wrangler secret put MASTER_SECRET`
220+
- (Optional legacy) `wrangler secret put SIGNING_SECRET`
213221

214-
> Important: If `REQUIRE_SIGNATURE=1` but `SIGNING_SECRET` is not set, the Worker will silently ignore all canary hits (returns `204` but does not store events).
222+
> Important: If `REQUIRE_SIGNATURE=1` but neither `MASTER_SECRET` nor `SIGNING_SECRET` is set, the Worker will silently ignore all canary hits (returns `204` but does not store events).
215223
216224
2) Generate signed URLs:
217225

218-
- UI: open the Token Studio (GitHub Pages `docs/index.html` or `public/index.html`) and enter `SIGNING_SECRET` under Advanced.
219-
- CLI: `python3 scripts/gen_signed_url.py --base-url https://<your-domain>/canary --src <source_id>`
226+
- UI (recommended): call the signing endpoint `/admin/sign` from the Token Studio.
227+
- Set `ADMIN_KEY` and keep `ALLOW_PUBLIC_SIGN=0` (recommended).
228+
- The UI can pass `Authorization: Bearer <ADMIN_KEY>`.
229+
- CLI: `python3 scripts/gen_signed_url.py --base-url https://<your-domain>/canary --master-secret <MASTER_SECRET> --src <source_id>`
230+
231+
### Admin signing endpoint
232+
233+
`GET /admin/sign?token=<token>&src=<src>`
234+
235+
- Response: `{ "url": "https://<host>/canary/<token>?ts=...&nonce=...&src=...&sig=..." }`
236+
- Protection:
237+
- Default: requires `Authorization: Bearer <ADMIN_KEY>`
238+
- Set `ALLOW_PUBLIC_SIGN=1` to disable auth (not recommended for public exposure)
239+
240+
## Deploy notes (GitHub Actions)
241+
242+
This repo includes a workflow that deploys on every push to `main`.
243+
It also syncs selected GitHub repo secrets into Worker secrets before deploying.
244+
245+
- Required Worker secrets for signed URLs:
246+
- `MASTER_SECRET` (recommended) or `SIGNING_SECRET` (fallback)
247+
- `ADMIN_KEY` (to protect `/admin/sign`)
248+
- `IP_HMAC_KEY`
249+
- GitHub Actions repo secrets are not automatically available to the Worker runtime.
250+
- This workflow copies selected values into Worker secrets using `wrangler secret put`.
251+
- It does not update signing secrets; keep `MASTER_SECRET`/`SIGNING_SECRET` managed on the Cloudflare side.
220252

221253
### 5) Cloudflare security controls (strongly recommended)
222254

docs/index.html

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,17 @@ <h1>Kanariya Token Studio</h1>
155155
<summary>Advanced</summary>
156156
<div class="row">
157157
<div>
158-
<label>Signing key (optional)</label>
159-
<input id="signingKey" type="password" placeholder="SIGNING_SECRET" />
158+
<label>Source id (src)</label>
159+
<input id="sourceId" placeholder="form_invoice_2026q1" />
160+
<div class="hint">Optional. Helps identify where the token was planted.</div>
161+
</div>
162+
<div>
163+
<label>Admin key (optional)</label>
164+
<input id="adminKey" type="password" placeholder="ADMIN_KEY (for /admin/sign)" />
165+
<div class="hint">Used only to request signed URLs from the server.</div>
160166
</div>
161167
</div>
162-
<div class="hint">If signature is required, enter the signing key to generate signed URLs.</div>
168+
<div class="hint">When signatures are required, this UI requests a signed URL from <code>/admin/sign</code>.</div>
163169
</details>
164170
</section>
165171

@@ -205,7 +211,8 @@ <h1>Kanariya Token Studio</h1>
205211
const tokenUrlOutput = document.getElementById("tokenUrl");
206212
const htmlBeaconOutput = document.getElementById("htmlBeacon");
207213
const tokenStatus = document.getElementById("tokenStatus");
208-
const signingKeyInput = document.getElementById("signingKey");
214+
const sourceIdInput = document.getElementById("sourceId");
215+
const adminKeyInput = document.getElementById("adminKey");
209216
const adminTokenInput = document.getElementById("adminToken");
210217
const exportStatus = document.getElementById("exportStatus");
211218
const exportOutput = document.getElementById("exportOutput");
@@ -238,46 +245,50 @@ <h1>Kanariya Token Studio</h1>
238245
.join("&");
239246
}
240247

241-
async function hmacHex(secret, value) {
242-
const encoder = new TextEncoder();
243-
const key = await crypto.subtle.importKey(
244-
"raw",
245-
encoder.encode(secret),
246-
{ name: "HMAC", hash: "SHA-256" },
247-
false,
248-
["sign"]
249-
);
250-
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
251-
return [...new Uint8Array(signature)]
252-
.map((b) => b.toString(16).padStart(2, "0"))
253-
.join("");
248+
async function fetchSignedUrl({ token, src }) {
249+
const params = new URLSearchParams();
250+
params.set("token", token);
251+
if (src) params.set("src", src);
252+
const url = `${ADMIN_BASE_URL}/admin/sign?${params.toString()}`;
253+
254+
const headers = {};
255+
const adminKey = (adminKeyInput.value || "").trim();
256+
if (adminKey) headers.authorization = `Bearer ${adminKey}`;
257+
258+
const response = await fetch(url, { method: "GET", headers });
259+
const bodyText = await response.text();
260+
if (!response.ok) {
261+
throw new Error(`${response.status}: ${bodyText}`);
262+
}
263+
const parsed = JSON.parse(bodyText);
264+
if (!parsed || !parsed.url) {
265+
throw new Error("Invalid /admin/sign response");
266+
}
267+
return parsed.url;
254268
}
255269

256270
async function buildOutputs() {
257271
const tokenBytes = 16;
258272
const token = generateToken(tokenBytes);
259-
const signingKey = signingKeyInput.value.trim();
260-
const basePath = new URL(BASE_URL);
261-
const path = `${basePath.pathname}/${token}`.replace(/\/{2,}/g, "/");
273+
const src = (sourceIdInput.value || "").trim();
274+
const params = new URLSearchParams();
275+
if (src) params.set("src", src);
276+
262277
let url = `${BASE_URL}/${token}`;
263-
if (signingKey) {
264-
const ts = Math.floor(Date.now() / 1000);
265-
const nonce = toBase64Url(crypto.getRandomValues(new Uint8Array(8)));
266-
const params = new URLSearchParams();
267-
params.set("ts", String(ts));
268-
params.set("nonce", nonce);
269-
const query = canonicalQuery(params);
270-
const stringToSign = `${ts}|${path}|${query}`;
271-
const sig = await hmacHex(signingKey, stringToSign);
272-
url = `${BASE_URL}/${token}?${query}&sig=${sig}`;
278+
try {
279+
url = await fetchSignedUrl({ token, src });
280+
} catch (err) {
281+
if ([...params.keys()].length) {
282+
url = `${BASE_URL}/${token}?${canonicalQuery(params)}`;
283+
}
284+
tokenStatus.textContent = `Signed URL failed: ${err?.message || "unknown error"}`;
273285
}
274286

275287
tokenUrlOutput.value = url;
276288
htmlBeaconOutput.value = `<!doctype html>
277289
<meta charset="utf-8"/>
278290
<title>Kanariya Canary</title>
279291
<img src="${url}" style="display:none" alt="" />`;
280-
tokenStatus.textContent = "";
281292
adminTokenInput.value = token;
282293
}
283294

public/index.html

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,12 @@ <h1>Kanariya Token Studio</h1>
160160
<div class="hint">Optional. Helps identify where the token was planted.</div>
161161
</div>
162162
<div>
163-
<label>Signing key (optional)</label>
164-
<input id="signingKey" type="password" placeholder="SIGNING_SECRET" />
163+
<label>Admin key (optional)</label>
164+
<input id="adminKey" type="password" placeholder="ADMIN_KEY (for /admin/sign)" />
165+
<div class="hint">Used only to request signed URLs from the server.</div>
165166
</div>
166167
</div>
167-
<div class="hint">If signature is required, enter the signing key to generate signed URLs.</div>
168+
<div class="hint">When signatures are required, this UI requests a signed URL from <code>/admin/sign</code>.</div>
168169
</details>
169170
</section>
170171

@@ -210,8 +211,8 @@ <h1>Kanariya Token Studio</h1>
210211
const tokenUrlOutput = document.getElementById("tokenUrl");
211212
const htmlBeaconOutput = document.getElementById("htmlBeacon");
212213
const tokenStatus = document.getElementById("tokenStatus");
213-
const signingKeyInput = document.getElementById("signingKey");
214214
const sourceIdInput = document.getElementById("sourceId");
215+
const adminKeyInput = document.getElementById("adminKey");
215216
const adminTokenInput = document.getElementById("adminToken");
216217
const exportStatus = document.getElementById("exportStatus");
217218
const exportOutput = document.getElementById("exportOutput");
@@ -244,51 +245,51 @@ <h1>Kanariya Token Studio</h1>
244245
.join("&");
245246
}
246247

247-
async function hmacHex(secret, value) {
248-
const encoder = new TextEncoder();
249-
const key = await crypto.subtle.importKey(
250-
"raw",
251-
encoder.encode(secret),
252-
{ name: "HMAC", hash: "SHA-256" },
253-
false,
254-
["sign"]
255-
);
256-
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
257-
return [...new Uint8Array(signature)]
258-
.map((b) => b.toString(16).padStart(2, "0"))
259-
.join("");
248+
async function fetchSignedUrl({ token, src }) {
249+
const params = new URLSearchParams();
250+
params.set("token", token);
251+
if (src) params.set("src", src);
252+
const url = `${ADMIN_BASE_URL}/admin/sign?${params.toString()}`;
253+
254+
const headers = {};
255+
const adminKey = (adminKeyInput.value || "").trim();
256+
if (adminKey) headers.authorization = `Bearer ${adminKey}`;
257+
258+
const response = await fetch(url, { method: "GET", headers });
259+
const bodyText = await response.text();
260+
if (!response.ok) {
261+
throw new Error(`${response.status}: ${bodyText}`);
262+
}
263+
const parsed = JSON.parse(bodyText);
264+
if (!parsed || !parsed.url) {
265+
throw new Error("Invalid /admin/sign response");
266+
}
267+
return parsed.url;
260268
}
261269

262270
async function buildOutputs() {
263271
const tokenBytes = 16;
264272
const token = generateToken(tokenBytes);
265-
const signingKey = signingKeyInput.value.trim();
266273
const src = (sourceIdInput.value || "").trim();
267-
const basePath = new URL(BASE_URL);
268-
const path = `${basePath.pathname}/${token}`.replace(/\/{2,}/g, "/");
269274
const params = new URLSearchParams();
270275
if (src) params.set("src", src);
271276

272277
let url = `${BASE_URL}/${token}`;
273-
if (signingKey) {
274-
const ts = Math.floor(Date.now() / 1000);
275-
const nonce = toBase64Url(crypto.getRandomValues(new Uint8Array(8)));
276-
params.set("ts", String(ts));
277-
params.set("nonce", nonce);
278-
const query = canonicalQuery(params);
279-
const stringToSign = `${ts}|${path}|${query}`;
280-
const sig = await hmacHex(signingKey, stringToSign);
281-
url = `${BASE_URL}/${token}?${query}&sig=${sig}`;
282-
} else if ([...params.keys()].length) {
283-
url = `${BASE_URL}/${token}?${canonicalQuery(params)}`;
278+
try {
279+
url = await fetchSignedUrl({ token, src });
280+
} catch (err) {
281+
// Fallback to unsigned URL (may not be accepted when signatures are required).
282+
if ([...params.keys()].length) {
283+
url = `${BASE_URL}/${token}?${canonicalQuery(params)}`;
284+
}
285+
tokenStatus.textContent = `Signed URL failed: ${err?.message || "unknown error"}`;
284286
}
285287

286288
tokenUrlOutput.value = url;
287289
htmlBeaconOutput.value = `<!doctype html>
288290
<meta charset="utf-8"/>
289291
<title>Kanariya Canary</title>
290292
<img src="${url}" style="display:none" alt="" />`;
291-
tokenStatus.textContent = "";
292293
adminTokenInput.value = token;
293294
}
294295

scripts/gen_signed_url.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,34 @@ def hmac_hex(secret, value):
3131
return hmac.new(secret.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
3232

3333

34+
def derived_signing_key(master_secret, token):
35+
return hmac_hex(master_secret, f"token:{token}")
36+
37+
3438
def main():
3539
parser = argparse.ArgumentParser(description="Generate signed Kanariya URLs.")
3640
parser.add_argument("--base-url", default="https://kanariya.toppymicros.com/canary")
3741
parser.add_argument("--token", default="")
3842
parser.add_argument("--src", default="")
39-
parser.add_argument("--secret", default=os.getenv("SIGNING_SECRET", ""))
43+
parser.add_argument(
44+
"--master-secret",
45+
default=os.getenv("MASTER_SECRET", ""),
46+
help="Master secret for per-token derived signing (recommended).",
47+
)
48+
parser.add_argument(
49+
"--secret",
50+
default=os.getenv("SIGNING_SECRET", ""),
51+
help="Legacy signing secret (fallback if --master-secret is not set).",
52+
)
4053
parser.add_argument("--nonce", default="")
4154
parser.add_argument("--bytes", type=int, default=16)
4255
args = parser.parse_args()
4356

44-
if not args.secret:
45-
raise SystemExit("SIGNING_SECRET is required (use --secret or env).")
57+
if not args.master_secret and not args.secret:
58+
raise SystemExit(
59+
"MASTER_SECRET is required (use --master-secret or env). "
60+
"Alternatively provide legacy SIGNING_SECRET via --secret."
61+
)
4662

4763
token = args.token or generate_token(max(8, args.bytes))
4864
base_url = args.base_url.rstrip("/")
@@ -60,7 +76,11 @@ def main():
6076

6177
query = canonical_query(params)
6278
string_to_sign = f"{ts}|{path}|{query}"
63-
sig = hmac_hex(args.secret, string_to_sign)
79+
if args.master_secret:
80+
per_token = derived_signing_key(args.master_secret, token)
81+
sig = hmac_hex(per_token, string_to_sign)
82+
else:
83+
sig = hmac_hex(args.secret, string_to_sign)
6484

6585
signed_query = f"{query}&sig={sig}"
6686
url = urllib.parse.urlunparse(

0 commit comments

Comments
 (0)