Add Cloudflare signing key contract
This commit is contained in:
@@ -43,3 +43,29 @@ jobs:
|
|||||||
|
|
||||||
- name: Audit source file size
|
- name: Audit source file size
|
||||||
run: scripts/audit_source_lines.sh
|
run: scripts/audit_source_lines.sh
|
||||||
|
|
||||||
|
cloudflare:
|
||||||
|
name: Cloudflare worker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: cloudflare/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: cloudflare
|
||||||
|
|
||||||
|
- name: Check worker types
|
||||||
|
run: npm run check
|
||||||
|
working-directory: cloudflare
|
||||||
|
|
||||||
|
- name: Test worker contracts
|
||||||
|
run: npm test
|
||||||
|
working-directory: cloudflare
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/target/
|
/target/
|
||||||
|
/cloudflare/dist/
|
||||||
|
/cloudflare/node_modules/
|
||||||
/references/
|
/references/
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
Generated
+1593
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@elydora/cloudflare-worker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"check": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"test": "tsc -p tsconfig.test.json --outDir dist && node --test dist/tests/*.test.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "4.20260508.1",
|
||||||
|
"@types/node": "24.12.3",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"wrangler": "4.90.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Env {
|
||||||
|
ELY_KV: KVNamespace;
|
||||||
|
ELY_ENVIRONMENT: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Env } from "./bindings.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetch(_request: Request, _env: Env): Response {
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
const KEY_ID_PATTERN = /^[a-z0-9._-]{3,128}$/;
|
||||||
|
const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
|
const KV_NAMESPACE_PREFIX = "ely";
|
||||||
|
const PUBLIC_SIGNING_KEYS_NAMESPACE = "public_signing_keys";
|
||||||
|
|
||||||
|
export interface PublicSigningKey {
|
||||||
|
key_id: string;
|
||||||
|
public_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicSigningKeysDocument {
|
||||||
|
version: 1;
|
||||||
|
keys: PublicSigningKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SigningKeysSchemaError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SigningKeysSchemaError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicSigningKeysKvKey(environment: string): string {
|
||||||
|
const normalizedEnvironment = normalizedEnvironmentName(environment);
|
||||||
|
return `${KV_NAMESPACE_PREFIX}:${normalizedEnvironment}:${PUBLIC_SIGNING_KEYS_NAMESPACE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePublicSigningKeysDocument(value: string): PublicSigningKeysDocument {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
throw new SigningKeysSchemaError("public signing keys document must be valid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
throw new SigningKeysSchemaError("public signing keys document must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.version !== 1) {
|
||||||
|
throw new SigningKeysSchemaError("public signing keys document version must be 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0) {
|
||||||
|
throw new SigningKeysSchemaError("public signing keys document must contain keys");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
keys: parsePublicSigningKeys(parsed.keys),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePublicSigningKeys(values: unknown[]): PublicSigningKey[] {
|
||||||
|
const keys: PublicSigningKey[] = [];
|
||||||
|
const seenKeyIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const key = parsePublicSigningKey(value);
|
||||||
|
if (seenKeyIds.has(key.key_id)) {
|
||||||
|
throw new SigningKeysSchemaError(`duplicate public signing key id: ${key.key_id}`);
|
||||||
|
}
|
||||||
|
seenKeyIds.add(key.key_id);
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePublicSigningKey(value: unknown): PublicSigningKey {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new SigningKeysSchemaError("public signing key must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyId = stringField(value, "key_id");
|
||||||
|
const publicKey = stringField(value, "public_key").toLowerCase();
|
||||||
|
|
||||||
|
if (!KEY_ID_PATTERN.test(keyId)) {
|
||||||
|
throw new SigningKeysSchemaError(`invalid public signing key id: ${keyId}`);
|
||||||
|
}
|
||||||
|
if (!PUBLIC_KEY_PATTERN.test(publicKey)) {
|
||||||
|
throw new SigningKeysSchemaError(`invalid public signing key value for ${keyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { key_id: keyId, public_key: publicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedEnvironmentName(value: string): string {
|
||||||
|
const environment = value.trim();
|
||||||
|
if (!/^[a-z0-9._-]{3,64}$/.test(environment)) {
|
||||||
|
throw new SigningKeysSchemaError(`invalid environment name: ${value}`);
|
||||||
|
}
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(value: Record<string, unknown>, field: string): string {
|
||||||
|
const fieldValue = value[field];
|
||||||
|
if (typeof fieldValue !== "string" || fieldValue.trim() === "") {
|
||||||
|
throw new SigningKeysSchemaError(`${field} must be a non-empty string`);
|
||||||
|
}
|
||||||
|
return fieldValue.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SigningKeysSchemaError,
|
||||||
|
parsePublicSigningKeysDocument,
|
||||||
|
publicSigningKeysKvKey,
|
||||||
|
} from "../src/signing_keys.js";
|
||||||
|
|
||||||
|
const PUBLIC_KEY = "a".repeat(64);
|
||||||
|
|
||||||
|
describe("public signing keys", () => {
|
||||||
|
it("builds an environment-prefixed KV key", () => {
|
||||||
|
assert.equal(publicSigningKeysKvKey("production"), "ely:production:public_signing_keys");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a valid key document", () => {
|
||||||
|
const document = parsePublicSigningKeysDocument(
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
keys: [{ key_id: "elydora-alpha-plugins", public_key: PUBLIC_KEY.toUpperCase() }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(document, {
|
||||||
|
version: 1,
|
||||||
|
keys: [{ key_id: "elydora-alpha-plugins", public_key: PUBLIC_KEY }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate key ids", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parsePublicSigningKeysDocument(
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
keys: [
|
||||||
|
{ key_id: "elydora-alpha-plugins", public_key: PUBLIC_KEY },
|
||||||
|
{ key_id: "elydora-alpha-plugins", public_key: "b".repeat(64) },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
SigningKeysSchemaError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed public keys", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parsePublicSigningKeysDocument(
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
keys: [{ key_id: "elydora-alpha-plugins", public_key: "abcd" }],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
SigningKeysSchemaError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": false,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": false,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/signing_keys.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
name = "ely-browser-cloud"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2026-05-08"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
ELY_ENVIRONMENT = "local"
|
||||||
@@ -10,6 +10,6 @@ while IFS= read -r -d '' file; do
|
|||||||
echo "${file}: ${lines} lines exceeds ${max_lines}"
|
echo "${file}: ${lines} lines exceeds ${max_lines}"
|
||||||
status=1
|
status=1
|
||||||
fi
|
fi
|
||||||
done < <(find crates -name '*.rs' -print0)
|
done < <(find crates cloudflare/src cloudflare/tests \( -name '*.rs' -o -name '*.ts' \) -print0)
|
||||||
|
|
||||||
exit "${status}"
|
exit "${status}"
|
||||||
|
|||||||
Reference in New Issue
Block a user