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)
286 lines
10 KiB
JavaScript
286 lines
10 KiB
JavaScript
/**
|
|
* Protocol conformance check: drive the real Worker with a mock KV and verify
|
|
* the response the way the Android client does.
|
|
*
|
|
* The client path being reproduced is RemoteActivationVerifier.verifySignature:
|
|
* - the signed payload is rebuilt from the response fields
|
|
* - the signature is ECDSA / SHA-256 / P-256 in P1363 (r || s) form, which is
|
|
* what Java's "SHA256withECDSA" consumes
|
|
* - the nonce must match the one the request sent
|
|
*
|
|
* Run with: npm run check (or: node test/protocol-check.mjs)
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import { webcrypto } from "node:crypto";
|
|
import worker from "../src/index.js";
|
|
|
|
// Node 18 exposes WebCrypto globally, but be explicit in case it is stripped.
|
|
if (!globalThis.crypto) globalThis.crypto = webcrypto;
|
|
|
|
const tests = [];
|
|
function test(name, fn) {
|
|
tests.push([name, fn]);
|
|
}
|
|
|
|
/** Minimal Workers KV stand-in. */
|
|
function mockKv(initial = {}) {
|
|
const store = new Map(Object.entries(initial));
|
|
return {
|
|
store,
|
|
async get(key, options) {
|
|
const value = store.get(key);
|
|
if (value === undefined) return null;
|
|
return options?.type === "json" ? JSON.parse(value) : value;
|
|
},
|
|
async put(key, value) {
|
|
store.set(key, value);
|
|
},
|
|
};
|
|
}
|
|
|
|
async function generateSigningKey() {
|
|
const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
|
|
"sign",
|
|
"verify",
|
|
]);
|
|
const pkcs8 = Buffer.from(await crypto.subtle.exportKey("pkcs8", pair.privateKey)).toString(
|
|
"base64"
|
|
);
|
|
const spki = Buffer.from(await crypto.subtle.exportKey("spki", pair.publicKey));
|
|
return { pkcs8, spki };
|
|
}
|
|
|
|
function publicKeyFromSpki(spki) {
|
|
return crypto.createPublicKey({ key: spki, format: "der", type: "spki" });
|
|
}
|
|
|
|
/**
|
|
* Rebuilds the payload exactly as RemoteActivationVerifier.canonicalSignedPayload
|
|
* does on the client, then verifies the signature from the response.
|
|
*/
|
|
function verifyLikeClient(publicKey, response, expectedNonce, includeUrl) {
|
|
assert.ok(response.sig, "response is missing the `sig` field");
|
|
assert.equal(response.nonce, expectedNonce, "nonce was not echoed back");
|
|
|
|
const fields = [
|
|
`"ok":${response.ok === true}`,
|
|
`"expiresAt":${response.expiresAt == null ? 0 : Math.trunc(response.expiresAt)}`,
|
|
`"remainingUses":${response.remainingUses == null ? -1 : Math.trunc(response.remainingUses)}`,
|
|
`"nonce":${JSON.stringify(response.nonce)}`,
|
|
];
|
|
if (includeUrl) fields.push(`"url":${JSON.stringify(response.url ?? "")}`);
|
|
|
|
const payload = `{${fields.join(",")}}`;
|
|
const signature = Buffer.from(response.sig, "base64");
|
|
return crypto.verify(
|
|
"sha256",
|
|
Buffer.from(payload, "utf8"),
|
|
{ key: publicKey, dsaEncoding: "ieee-p1363" },
|
|
signature
|
|
);
|
|
}
|
|
|
|
/** Mirrors RemoteActivationVerifier.decryptUrl: Base64(IV[12] || ct || tag[16]). */
|
|
function decryptUrlLikeClient(encryptedBase64, aesKeyBase64) {
|
|
const combined = Buffer.from(encryptedBase64, "base64");
|
|
const iv = combined.subarray(0, 12);
|
|
const tag = combined.subarray(combined.length - 16);
|
|
const ciphertext = combined.subarray(12, combined.length - 16);
|
|
const decipher = crypto.createDecipheriv("aes-256-gcm", Buffer.from(aesKeyBase64, "base64"), iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
}
|
|
|
|
async function post(body, env) {
|
|
const response = await worker.fetch(
|
|
new Request("https://activation.example.com/verify", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
env
|
|
);
|
|
return { status: response.status, body: await response.json() };
|
|
}
|
|
|
|
test("a valid code produces a signature the client accepts", async () => {
|
|
const { pkcs8, spki } = await generateSigningKey();
|
|
const expiresAt = Date.now() + 86_400_000;
|
|
const env = {
|
|
SIGNING_KEY: pkcs8,
|
|
ACTIVATION_CODES: mockKv({
|
|
"code:DEMO1234": JSON.stringify({ expiresAt, maxDevices: 1, devices: [] }),
|
|
}),
|
|
};
|
|
|
|
const { status, body } = await post(
|
|
{ code: "demo1234", deviceId: "device-a", packageName: "com.example.app", nonce: "nonce-1", ts: Date.now() },
|
|
env
|
|
);
|
|
|
|
assert.equal(status, 200);
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.expiresAt, expiresAt);
|
|
assert.ok(verifyLikeClient(publicKeyFromSpki(spki), body, "nonce-1", false));
|
|
});
|
|
|
|
test("unknown code is signed as a rejection rather than failing open", async () => {
|
|
const { pkcs8, spki } = await generateSigningKey();
|
|
const env = { SIGNING_KEY: pkcs8, ACTIVATION_CODES: mockKv() };
|
|
|
|
const { body } = await post({ code: "NOPE", deviceId: "d", nonce: "nonce-2", ts: Date.now() }, env);
|
|
|
|
assert.equal(body.ok, false);
|
|
assert.ok(verifyLikeClient(publicKeyFromSpki(spki), body, "nonce-2", false));
|
|
});
|
|
|
|
test("an expired code is rejected", async () => {
|
|
const { pkcs8, spki } = await generateSigningKey();
|
|
const env = {
|
|
SIGNING_KEY: pkcs8,
|
|
ACTIVATION_CODES: mockKv({
|
|
"code:OLD": JSON.stringify({ expiresAt: Date.now() - 1000 }),
|
|
}),
|
|
};
|
|
|
|
const { body } = await post({ code: "OLD", deviceId: "d", nonce: "nonce-3", ts: Date.now() }, env);
|
|
|
|
assert.equal(body.ok, false);
|
|
assert.match(body.message, /expired/);
|
|
assert.ok(verifyLikeClient(publicKeyFromSpki(spki), body, "nonce-3", false));
|
|
});
|
|
|
|
test("a device-bound code rejects a second device", async () => {
|
|
const { pkcs8, spki } = await generateSigningKey();
|
|
const env = {
|
|
SIGNING_KEY: pkcs8,
|
|
ACTIVATION_CODES: mockKv({
|
|
"code:ONESEAT": JSON.stringify({ maxDevices: 1, devices: ["device-a"] }),
|
|
}),
|
|
};
|
|
|
|
const first = await post({ code: "ONESEAT", deviceId: "device-a", nonce: "n-a", ts: Date.now(), deviceBound: true }, env);
|
|
assert.equal(first.body.ok, true, "the bound device may re-verify");
|
|
assert.equal(first.body.remainingUses, 0);
|
|
assert.ok(verifyLikeClient(publicKeyFromSpki(spki), first.body, "n-a", false));
|
|
|
|
const second = await post({ code: "ONESEAT", deviceId: "device-b", nonce: "n-b", ts: Date.now(), deviceBound: true }, env);
|
|
assert.equal(second.body.ok, false);
|
|
assert.match(second.body.message, /another device/);
|
|
assert.ok(verifyLikeClient(publicKeyFromSpki(spki), second.body, "n-b", false));
|
|
});
|
|
|
|
test("a delivered url is inside the signed payload", async () => {
|
|
const { pkcs8, spki } = await generateSigningKey();
|
|
const env = {
|
|
SIGNING_KEY: pkcs8,
|
|
ACTIVATION_CODES: mockKv({
|
|
"code:WITHURL": JSON.stringify({ url: "https://example.com/target" }),
|
|
}),
|
|
};
|
|
|
|
const { body } = await post({ code: "WITHURL", deviceId: "d", nonce: "nonce-4", ts: Date.now() }, env);
|
|
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.url, "https://example.com/target");
|
|
// Client has Deliver URL enabled, so its payload includes the url field.
|
|
assert.ok(verifyLikeClient(publicKeyFromSpki(spki), body, "nonce-4", true));
|
|
// And the legacy payload (without url) must NOT verify — proving the url is
|
|
// actually protected and not merely riding along unsigned.
|
|
assert.equal(verifyLikeClient(publicKeyFromSpki(spki), body, "nonce-4", false), false);
|
|
});
|
|
|
|
test("an encrypted url decrypts with the format the client expects", async () => {
|
|
const { pkcs8, spki } = await generateSigningKey();
|
|
const aesKey = crypto.randomBytes(32).toString("base64");
|
|
const env = {
|
|
SIGNING_KEY: pkcs8,
|
|
AES_KEY: aesKey,
|
|
ACTIVATION_CODES: mockKv({
|
|
"code:SECURE": JSON.stringify({ url: "https://example.com/secret", encryptUrl: true }),
|
|
}),
|
|
};
|
|
|
|
const { body } = await post({ code: "SECURE", deviceId: "d", nonce: "nonce-5", ts: Date.now() }, env);
|
|
|
|
assert.equal(body.ok, true);
|
|
assert.notEqual(body.url, "https://example.com/secret", "url must not be sent in the clear");
|
|
assert.ok(verifyLikeClient(publicKeyFromSpki(spki), body, "nonce-5", true));
|
|
assert.equal(decryptUrlLikeClient(body.url, aesKey), "https://example.com/secret");
|
|
});
|
|
|
|
test("a stale timestamp is rejected", async () => {
|
|
const { pkcs8, spki } = await generateSigningKey();
|
|
const env = {
|
|
SIGNING_KEY: pkcs8,
|
|
MAX_CLOCK_SKEW_MS: "60000",
|
|
ACTIVATION_CODES: mockKv({ "code:FRESH": JSON.stringify({}) }),
|
|
};
|
|
|
|
const stale = Date.now() - 3_600_000;
|
|
const { body } = await post({ code: "FRESH", deviceId: "d", nonce: "nonce-6", ts: stale }, env);
|
|
|
|
assert.equal(body.ok, false);
|
|
assert.match(body.message, /timestamp/);
|
|
assert.ok(verifyLikeClient(publicKeyFromSpki(spki), body, "nonce-6", false));
|
|
});
|
|
|
|
test("GET returns a usable public key and no secrets", async () => {
|
|
const { pkcs8, spki } = await generateSigningKey();
|
|
const response = await worker.fetch(new Request("https://activation.example.com/"), {
|
|
SIGNING_KEY: pkcs8,
|
|
ACTIVATION_CODES: mockKv(),
|
|
});
|
|
const body = await response.json();
|
|
|
|
assert.equal(body.ok, true);
|
|
assert.deepEqual(body.problems, []);
|
|
assert.match(body.keyFingerprint, /^[0-9a-f]{16}$/);
|
|
assert.equal(body.urlEncryption, "disabled");
|
|
|
|
// The point of the endpoint: the operator can copy this straight into the
|
|
// app's "Signature public key" field, so it must be the real counterpart.
|
|
const returned = crypto.createPublicKey({
|
|
key: Buffer.from(body.publicKey, "base64"),
|
|
format: "der",
|
|
type: "spki",
|
|
});
|
|
assert.equal(
|
|
returned.export({ type: "spki", format: "der" }).toString("base64"),
|
|
spki.toString("base64"),
|
|
"GET must return the public half of SIGNING_KEY"
|
|
);
|
|
|
|
assert.equal(JSON.stringify(body).includes(pkcs8), false, "private key must never be returned");
|
|
});
|
|
|
|
test("GET reports a missing KV binding instead of failing silently", async () => {
|
|
const { pkcs8 } = await generateSigningKey();
|
|
const response = await worker.fetch(new Request("https://activation.example.com/"), {
|
|
SIGNING_KEY: pkcs8,
|
|
});
|
|
const body = await response.json();
|
|
|
|
assert.equal(body.ok, false);
|
|
assert.ok(
|
|
body.problems.some((problem) => problem.includes("ACTIVATION_CODES")),
|
|
`expected a KV problem, got ${JSON.stringify(body.problems)}`
|
|
);
|
|
});
|
|
|
|
let failures = 0;
|
|
for (const [name, fn] of tests) {
|
|
try {
|
|
await fn();
|
|
console.log(` ok ${name}`);
|
|
} catch (error) {
|
|
failures += 1;
|
|
console.error(` FAIL ${name}\n ${error.message}`);
|
|
}
|
|
}
|
|
|
|
console.log(`\n${tests.length - failures}/${tests.length} passed`);
|
|
process.exit(failures === 0 ? 0 : 1);
|