@pithy-sh/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The control-plane scope model. Scopes are **per management operation, not per credential-holder**:
|
|
8
|
+
* a credential that may read purchases cannot revoke an entitlement unless separately scoped, and
|
|
9
|
+
* anything unscoped is denied.
|
|
10
|
+
*
|
|
11
|
+
* Scopes federate the way audit actions and migration namespaces do — a capability declares its own
|
|
12
|
+
* (`payments:entitlements:grant`) without touching core, so this is an open, pattern-validated string
|
|
13
|
+
* rather than a closed union. Core holds only the seam's own scopes and the matching rule.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** `resource:action`, or `resource:sub:action` — lowercase segments joined by colons. */
|
|
17
|
+
const SCOPE_PATTERN = /^[a-z][a-z0-9]*(?::[a-z][a-z0-9_]*)+$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One granted operation. Validated on the way into the connection row and on the way out of a token,
|
|
21
|
+
* so a malformed scope is a parse failure at the boundary rather than a silent never-match — a scope
|
|
22
|
+
* that reads as a grant in the row and denies at every call looks like a bug in the seam.
|
|
23
|
+
*/
|
|
24
|
+
export const ControlPlaneScope = z
|
|
25
|
+
.string()
|
|
26
|
+
.regex(
|
|
27
|
+
SCOPE_PATTERN,
|
|
28
|
+
"A control-plane scope must be lowercase colon-separated segments naming one operation (e.g. `manifest:read`, `payments:entitlements:grant`).",
|
|
29
|
+
)
|
|
30
|
+
.describe(
|
|
31
|
+
"One management operation a connection may perform, as a colon-separated `resource:action` code. The taxonomy is federated: each capability declares its own scopes, and core holds only the seam's.",
|
|
32
|
+
);
|
|
33
|
+
export type ControlPlaneScope = z.output<typeof ControlPlaneScope>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The requirement for a route that needs a **verified caller but no authorization** — today, only
|
|
37
|
+
* `GET /control-plane/ping`.
|
|
38
|
+
*
|
|
39
|
+
* Ping is not a scope, and modeling it as one would be dishonest: granting it would change nothing
|
|
40
|
+
* and withholding it would change nothing, because ping must work for a connection granted nothing at
|
|
41
|
+
* all. It is the call that proves a newly registered key works *before* the key it replaces is
|
|
42
|
+
* expired, and that ordering is the entire safety property of rotation. A ping that could be withheld
|
|
43
|
+
* would let a connection reach a state where the replacement can never be proven — and then either the
|
|
44
|
+
* old key is never expired, or it is expired anyway and nobody can get back in.
|
|
45
|
+
*
|
|
46
|
+
* A symbol rather than a magic string, so it is unforgeable by construction. Claims arrive over the
|
|
47
|
+
* wire as JSON, and no JSON value is ever equal to this — a caller cannot present it, only a route can
|
|
48
|
+
* require it.
|
|
49
|
+
*/
|
|
50
|
+
export const ANY_VERIFIED_CALLER: unique symbol = Symbol.for("pithy.controlPlane.anyVerifiedCaller");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a route demands of a caller: one named operation, or merely that it verified.
|
|
54
|
+
*
|
|
55
|
+
* `requireControlPlane` takes this rather than an optional scope on purpose. An optional argument is a
|
|
56
|
+
* thing you can forget, and forgetting it on an admin route would mean shipping an unscoped one; a
|
|
57
|
+
* required argument means the only way to write a route with no authorization is to say
|
|
58
|
+
* {@link ANY_VERIFIED_CALLER} out loud, which is as deliberate as naming a scope.
|
|
59
|
+
*/
|
|
60
|
+
export type ControlPlaneRequirement = ControlPlaneScope | typeof ANY_VERIFIED_CALLER;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read this Worker's composed capability manifest — the discovery-over-configuration call.
|
|
64
|
+
*
|
|
65
|
+
* Annotated `: ControlPlaneScope` like every capability's scope, and not merely for symmetry: that
|
|
66
|
+
* annotation is how `tooling/browser-scopes` finds a control-plane scope at all. Matching the type
|
|
67
|
+
* rather than the `_SCOPE` suffix is what keeps `PLAY_SCOPE` (a Google OAuth URL) and `GLOBAL_SCOPE`
|
|
68
|
+
* (an environment name) out of a gate that has nothing to say about either.
|
|
69
|
+
*/
|
|
70
|
+
export const MANIFEST_READ_SCOPE: ControlPlaneScope = "manifest:read";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register a new public key, expire a superseded one, and read the registration state. One scope for
|
|
74
|
+
* the whole key lifecycle, granted separately at connect: an adopter who does not want a management
|
|
75
|
+
* client changing keys on a schedule simply does not grant it, and then it *cannot*, whatever the
|
|
76
|
+
* client intends. Better than a toggle anyone has to be trusted to honor.
|
|
77
|
+
*/
|
|
78
|
+
export const KEYS_ROTATE_SCOPE: ControlPlaneScope = "keys:rotate";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every grantable scope the seam's own routes use — what `pithy dashboard connect` offers by default.
|
|
82
|
+
* Ping is absent because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
|
|
83
|
+
*/
|
|
84
|
+
export const SEAM_SCOPES: readonly ControlPlaneScope[] = [MANIFEST_READ_SCOPE, KEYS_ROTATE_SCOPE];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Does this call carry what the route requires?
|
|
88
|
+
*
|
|
89
|
+
* Both sides must agree, and they are checked against different things on purpose. `granted` is what
|
|
90
|
+
* the **adopter** stored on the connection; `presented` is the single scope the **caller** put in the
|
|
91
|
+
* token for this one call. A scope the adopter never granted is denied however the token is written,
|
|
92
|
+
* and a token that claims a broad grant still only exercises the one operation it named.
|
|
93
|
+
*
|
|
94
|
+
* Matching is exact string equality. There is no prefix or wildcard rule, because
|
|
95
|
+
* `payments:entitlements` must not quietly confer `payments:entitlements:revoke` — prefix semantics on
|
|
96
|
+
* an authorization check is how a read grant becomes a write one without anybody deciding it should.
|
|
97
|
+
*/
|
|
98
|
+
export function scopeCovers(required: ControlPlaneRequirement, presented: string, granted: readonly string[]): boolean {
|
|
99
|
+
if (required === ANY_VERIFIED_CALLER) return true;
|
|
100
|
+
if (presented !== required) return false;
|
|
101
|
+
return granted.includes(required);
|
|
102
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ControlPlaneInvalidCredentialError } from "../error/errors";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* base64url, the encoding every part of a compact JWS is written in (RFC 4648 §5). Core ships no
|
|
8
|
+
* base64 helper, so the seam brings its own rather than reaching into another package's crypto.
|
|
9
|
+
*
|
|
10
|
+
* Two properties matter here and nowhere else in the tree.
|
|
11
|
+
*
|
|
12
|
+
* **Decoding is strict.** A token segment is attacker-supplied by definition, and `atob` is famously
|
|
13
|
+
* forgiving — it will happily digest whitespace and, on some runtimes, characters that mean nothing.
|
|
14
|
+
* Silently decoding malformed input produces bytes that parse into *something*, and something is
|
|
15
|
+
* exactly what an attacker wants a verifier to work with. So anything outside the url alphabet is a
|
|
16
|
+
* throw, and the throw is a credential failure that renders as a 401 through the one handler.
|
|
17
|
+
*
|
|
18
|
+
* **Padding is rejected, not tolerated.** RFC 7515 §2 mandates unpadded segments. Accepting `=`
|
|
19
|
+
* would give one token two spellings, and a token with two spellings is two strings to log, two to
|
|
20
|
+
* compare, and one more thing an equality check can be wrong about.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The url alphabet, unpadded. Whitespace, `+`, `/` and `=` are all outside it, deliberately. */
|
|
24
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
|
|
25
|
+
|
|
26
|
+
/** Encode raw bytes as unpadded base64url. */
|
|
27
|
+
export function base64UrlEncode(bytes: Uint8Array): string {
|
|
28
|
+
// Built one character at a time. `String.fromCharCode(...bytes)` overflows the argument stack on
|
|
29
|
+
// inputs this seam genuinely sees — a request body digest, an exported key — so the loop is the
|
|
30
|
+
// implementation, not a style choice. (Same reason as `secrets/src/crypto/envelope.ts`.)
|
|
31
|
+
let binary = "";
|
|
32
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
33
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decode unpadded base64url to raw bytes, or throw {@link ControlPlaneInvalidCredentialError}.
|
|
38
|
+
*
|
|
39
|
+
* Every rejection is the same error for the same reason the verification steps are: a caller learns
|
|
40
|
+
* that the credential is not valid here, never which character gave it away.
|
|
41
|
+
*/
|
|
42
|
+
export function base64UrlDecode(value: string): Uint8Array {
|
|
43
|
+
if (!BASE64URL_PATTERN.test(value)) {
|
|
44
|
+
throw new ControlPlaneInvalidCredentialError({
|
|
45
|
+
detail: "token segment is not unpadded base64url (RFC 4648 §5)",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// A base64 group is 2, 3 or 4 characters. One leftover character encodes no byte at all, so a
|
|
49
|
+
// length of 1 mod 4 is unrepresentable rather than merely odd — reject it before `atob` guesses.
|
|
50
|
+
if (value.length % 4 === 1) {
|
|
51
|
+
throw new ControlPlaneInvalidCredentialError({ detail: "token segment has an impossible base64url length" });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4);
|
|
55
|
+
let binary: string;
|
|
56
|
+
try {
|
|
57
|
+
binary = atob(padded);
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
throw new ControlPlaneInvalidCredentialError({ detail: "token segment failed base64url decode" }, { cause });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const bytes = new Uint8Array(binary.length);
|
|
63
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
64
|
+
return bytes;
|
|
65
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ControlPlaneScope } from "../scope/scope";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The token a management client presents: what it claims, and what shape the envelope around it must
|
|
9
|
+
* have. This is the wire contract between the hosted dashboard and an adopter's Worker.
|
|
10
|
+
*
|
|
11
|
+
* **Not Cloudflare's control plane.** That is the outbound provisioning REST API this project calls
|
|
12
|
+
* through `@pithy-sh/cloudflare`. This is the inbound admin seam, authenticated by the adopter, in the
|
|
13
|
+
* adopter's Worker. The names collide; nothing else does.
|
|
14
|
+
*
|
|
15
|
+
* These schemas describe *untrusted* data. Parsing one proves the token is well-formed and nothing
|
|
16
|
+
* more — a forged token parses exactly as cleanly as a real one. Authenticity comes only from
|
|
17
|
+
* `verifyEd25519` (`./jws`), and nothing may act on these values before it returns true.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long a token may live, from `iat` to `exp`.
|
|
22
|
+
*
|
|
23
|
+
* A minute is the whole budget: mint, send, verify. Nothing legitimate needs more, because the client
|
|
24
|
+
* mints per call rather than caching a credential.
|
|
25
|
+
*
|
|
26
|
+
* **This cap is what makes the replay window sound.** Replay defense is a `jti` held in a KV set with
|
|
27
|
+
* a TTL, and a set with a TTL only covers tokens that expire inside it. Without a cap, a client could
|
|
28
|
+
* mint an `exp` an hour out; the `jti` would age out after {@link CONTROL_PLANE_JTI_TTL_SECONDS} and
|
|
29
|
+
* the same token would then replay freely for the remaining fifty-odd minutes. So the cap is enforced
|
|
30
|
+
* at verification, not merely recommended — a token whose lifetime exceeds it is rejected however
|
|
31
|
+
* validly it is signed.
|
|
32
|
+
*/
|
|
33
|
+
export const CONTROL_PLANE_MAX_TOKEN_LIFETIME_SECONDS = 60;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How far the two clocks may disagree before a valid call is refused.
|
|
37
|
+
*
|
|
38
|
+
* Applied at both ends: a token is not "not yet valid" until it is this far in the future, and not
|
|
39
|
+
* expired until this far in the past. Machines that never sync do exist, and a management client
|
|
40
|
+
* locked out by a drifting clock has no way to notice, let alone fix it. A minute is generous enough
|
|
41
|
+
* to survive that and short enough to stay inside the replay window below.
|
|
42
|
+
*/
|
|
43
|
+
export const CONTROL_PLANE_CLOCK_SKEW_SECONDS = 60;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How long a spent `jti` stays in the replay set.
|
|
47
|
+
*
|
|
48
|
+
* It must **outlive** the longest window in which its token could still verify — the max lifetime plus
|
|
49
|
+
* a skew at each end, so 60 + 60 + 60 = 180. Shorter, and a token becomes replayable the moment its id
|
|
50
|
+
* ages out; longer only costs storage in a store whose entries expire on their own.
|
|
51
|
+
*
|
|
52
|
+
* 300 rather than exactly 180, because equal is not longer. At 180 the memory ends on the same instant
|
|
53
|
+
* the token stops being accepted, which leaves the boundary decided by whichever clock rounds first —
|
|
54
|
+
* and "the replay window is only open for the last instant" is not a property worth defending. The two
|
|
55
|
+
* extra minutes cost nothing and make the relation a strict inequality, which is what
|
|
56
|
+
* `ControlPlaneConfig` now enforces and `claims.test.ts` asserts.
|
|
57
|
+
*/
|
|
58
|
+
export const CONTROL_PLANE_JTI_TTL_SECONDS = 300;
|
|
59
|
+
|
|
60
|
+
/** A base64url SHA-256: 32 bytes, unpadded, exactly 43 characters. */
|
|
61
|
+
const BODY_SHA256_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The JWS protected header.
|
|
65
|
+
*
|
|
66
|
+
* **`alg` is a pinned literal, and that is the algorithm-confusion defense.** The classic JWT breaks
|
|
67
|
+
* are both header-driven: `"alg":"none"` asking the verifier to skip verification, and swapping RS256
|
|
68
|
+
* for HS256 so a *public* key is used as an HMAC secret. Both need the verifier to take its algorithm
|
|
69
|
+
* from the token. Here the schema decides and the token merely has to agree, so neither attack has a
|
|
70
|
+
* shape it can be written in — the header is rejected at parse, before a key is ever imported.
|
|
71
|
+
*/
|
|
72
|
+
export const ControlPlaneJwsHeader = z
|
|
73
|
+
.object({
|
|
74
|
+
alg: z
|
|
75
|
+
.literal("EdDSA")
|
|
76
|
+
.describe(
|
|
77
|
+
"The signature algorithm, pinned to EdDSA (Ed25519). A literal rather than a choice: the verifier never takes its algorithm from the token, so `none` and HMAC-for-RSA confusion are unrepresentable.",
|
|
78
|
+
),
|
|
79
|
+
typ: z
|
|
80
|
+
.literal("JWT")
|
|
81
|
+
.describe("The token type, always `JWT`. Pinned so a JWS meant for another purpose cannot be replayed here."),
|
|
82
|
+
kid: z
|
|
83
|
+
.string()
|
|
84
|
+
.min(1)
|
|
85
|
+
.max(64)
|
|
86
|
+
.describe(
|
|
87
|
+
"Which registered key signed this token, matched against the connection's `keys`. A hint for lookup only — an unknown or forged `kid` selects no key and the call is denied.",
|
|
88
|
+
),
|
|
89
|
+
})
|
|
90
|
+
.describe(
|
|
91
|
+
"The protected header of a control-plane token. Untrusted until the signature verifies; `alg` is pinned rather than read.",
|
|
92
|
+
);
|
|
93
|
+
export type ControlPlaneJwsHeader = z.infer<typeof ControlPlaneJwsHeader>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The token payload.
|
|
97
|
+
*
|
|
98
|
+
* Every claim is a check the Worker performs against something it already knows — the connection row,
|
|
99
|
+
* its own clock, its own environment, the body it just read. None of them are informational.
|
|
100
|
+
*/
|
|
101
|
+
export const ControlPlaneClaims = z
|
|
102
|
+
.object({
|
|
103
|
+
iss: z
|
|
104
|
+
.url()
|
|
105
|
+
.describe(
|
|
106
|
+
"The management client's origin. Checked against the `issuer` the adopter stored on the connection, so a token minted by another origin is denied even if it is validly signed.",
|
|
107
|
+
),
|
|
108
|
+
aud: z
|
|
109
|
+
.uuid()
|
|
110
|
+
.describe(
|
|
111
|
+
"The connection this token addresses — the row's UUID. A UUID because it is externally exposed; checked so a token for one adopter's connection cannot be presented to another's Worker.",
|
|
112
|
+
),
|
|
113
|
+
sub: z
|
|
114
|
+
.string()
|
|
115
|
+
.min(1)
|
|
116
|
+
.max(256)
|
|
117
|
+
.describe(
|
|
118
|
+
"The dashboard user acting, in the management client's own id space. Recorded as the audit `actorId` so the trail says which person acted, not merely which service.",
|
|
119
|
+
),
|
|
120
|
+
scope: ControlPlaneScope.describe(
|
|
121
|
+
"The single operation this token authorizes. One call, one scope: a token carrying the whole grant would make every call as dangerous as the most dangerous one it could make.",
|
|
122
|
+
),
|
|
123
|
+
jti: z
|
|
124
|
+
.string()
|
|
125
|
+
.min(1)
|
|
126
|
+
.max(128)
|
|
127
|
+
.describe(
|
|
128
|
+
"A unique id for this one token, claimed in the replay set on first use. Bounded so an unbounded string cannot be used to bloat that set.",
|
|
129
|
+
),
|
|
130
|
+
iat: z
|
|
131
|
+
.int()
|
|
132
|
+
.describe(
|
|
133
|
+
"When the token was minted, in seconds since the epoch. With `exp` it bounds the lifetime, which is capped at CONTROL_PLANE_MAX_TOKEN_LIFETIME_SECONDS.",
|
|
134
|
+
),
|
|
135
|
+
exp: z
|
|
136
|
+
.int()
|
|
137
|
+
.describe(
|
|
138
|
+
"When the token stops being accepted, in seconds since the epoch. Honored within CONTROL_PLANE_CLOCK_SKEW_SECONDS, and never further out than the max lifetime allows.",
|
|
139
|
+
),
|
|
140
|
+
bodySha256: z
|
|
141
|
+
.string()
|
|
142
|
+
.regex(BODY_SHA256_PATTERN, "bodySha256 must be a base64url SHA-256 digest.")
|
|
143
|
+
.nullable()
|
|
144
|
+
.describe(
|
|
145
|
+
"Base64url SHA-256 of the request body, binding this token to one call — or null for a request with no body. The signature covers the header and claims only, so without this a token could be lifted onto a different body for the same route.",
|
|
146
|
+
),
|
|
147
|
+
})
|
|
148
|
+
.describe(
|
|
149
|
+
"The claims a management client signs for exactly one control-plane call. Well-formed after parsing; authentic only after the signature verifies.",
|
|
150
|
+
);
|
|
151
|
+
export type ControlPlaneClaims = z.infer<typeof ControlPlaneClaims>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { base64UrlEncode } from "./base64url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The two primitives the body-binding check needs: hash a request body, then compare that hash to the
|
|
8
|
+
* one the token committed to.
|
|
9
|
+
*
|
|
10
|
+
* Body binding is what stops a verified token being lifted off one call and stapled to another. The
|
|
11
|
+
* signature covers the header and claims, never the body — so the claims carry `bodySha256`, and the
|
|
12
|
+
* Worker recomputes it. Without that, an intercepted `keys:rotate` token would authorize *any*
|
|
13
|
+
* `keys:rotate` body, including one registering the interceptor's key.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SHA-256 a request body, base64url-encoded — the exact form `bodySha256` is written in.
|
|
18
|
+
*
|
|
19
|
+
* **A view is copied before it is hashed, and that is a fix rather than a formality** (#315).
|
|
20
|
+
* `crypto.subtle.digest` takes a `BufferSource`, which is `ArrayBufferView<ArrayBuffer> | ArrayBuffer`
|
|
21
|
+
* — a plain `Uint8Array` is `Uint8Array<ArrayBufferLike>`, which also admits a view onto a
|
|
22
|
+
* `SharedArrayBuffer`, and so does not satisfy it. `@cloudflare/workers-types` spells `BufferSource`
|
|
23
|
+
* loosely enough that the mismatch never surfaced in a Worker program; an adopter compiling this
|
|
24
|
+
* module against the DOM lib got a type error inside our source, with nothing on their side to fix.
|
|
25
|
+
*
|
|
26
|
+
* The copy is what makes the narrowing true rather than asserted, and the reason it is worth its cost:
|
|
27
|
+
* a digest of memory another thread can write is a check that does not bind what it checked. Copying
|
|
28
|
+
* fixes the bytes at the instant they are hashed, so `bodySha256` commits to what the verifier
|
|
29
|
+
* actually compared. The bodies here are small signed control-plane requests, and the alternative was
|
|
30
|
+
* a cast that would have kept the hazard and hidden the argument.
|
|
31
|
+
*/
|
|
32
|
+
export async function sha256Base64Url(bytes: ArrayBuffer | Uint8Array): Promise<string> {
|
|
33
|
+
const source = bytes instanceof Uint8Array ? new Uint8Array(bytes) : bytes;
|
|
34
|
+
const digest = await crypto.subtle.digest("SHA-256", source);
|
|
35
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compare two strings without leaking, through timing, where they first differ.
|
|
40
|
+
*
|
|
41
|
+
* The honest caveat first: `===` on a **digest** is a weak oracle at best. To exploit the early return
|
|
42
|
+
* an attacker must steer the digest byte by byte, which means finding preimages — so this is not the
|
|
43
|
+
* classic "guess the MAC one byte at a time" break that constant-time comparison was invented for.
|
|
44
|
+
*
|
|
45
|
+
* We do it anyway, for two reasons. The threat model is not fixed: this helper is the one every future
|
|
46
|
+
* comparison in the seam will reach for, and the next thing compared may well be a value an attacker
|
|
47
|
+
* *can* choose freely, at which point a plain `===` becomes a real break with no diff to notice it in.
|
|
48
|
+
* And the cost is a loop over 43 characters. Paying it always is cheaper than auditing, every time,
|
|
49
|
+
* whether this particular comparison happened to be safe.
|
|
50
|
+
*
|
|
51
|
+
* The scan runs to the end of the longer string and folds the length difference into the same
|
|
52
|
+
* accumulator, so neither an early match nor an early mismatch changes the work done.
|
|
53
|
+
*/
|
|
54
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
55
|
+
let difference = a.length ^ b.length;
|
|
56
|
+
const width = Math.max(a.length, b.length);
|
|
57
|
+
for (let i = 0; i < width; i++) {
|
|
58
|
+
// Past the end `charCodeAt` yields NaN; `|| 0` makes the overrun a zero rather than a branch. The
|
|
59
|
+
// length xor above is what keeps a NUL-padded string from reading as equal to a shorter one.
|
|
60
|
+
difference |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
61
|
+
}
|
|
62
|
+
return difference === 0;
|
|
63
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import type { Ed25519PublicJwk } from "../data/connection";
|
|
6
|
+
import { ControlPlaneInvalidCredentialError } from "../error/errors";
|
|
7
|
+
import { base64UrlDecode } from "./base64url";
|
|
8
|
+
import { ControlPlaneClaims, ControlPlaneJwsHeader } from "./claims";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compact JWS: parse, then verify. Two functions, in that order, and the order is the security model.
|
|
12
|
+
*
|
|
13
|
+
* **No JWT library.** Core's runtime dependencies are fixed and `jose` is not among them — but the
|
|
14
|
+
* better reason is that the whole surface is one algorithm, one key type, one token shape. Ed25519
|
|
15
|
+
* over WebCrypto is a dozen lines; a general-purpose library brings the algorithm agility that causes
|
|
16
|
+
* the JWT vulnerabilities in the first place. There is nothing to negotiate here, so nothing
|
|
17
|
+
* negotiates.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** What {@link parseCompactJws} hands back: shape only, authenticity not yet established. */
|
|
21
|
+
export interface ParsedCompactJws {
|
|
22
|
+
/** The validated protected header. `alg` is EdDSA because the schema says so, not because the token did. */
|
|
23
|
+
header: ControlPlaneJwsHeader;
|
|
24
|
+
/** The validated claims. Well-formed, entirely unproven. */
|
|
25
|
+
claims: ControlPlaneClaims;
|
|
26
|
+
/** The first two segments verbatim — the exact bytes the signature covers. */
|
|
27
|
+
signingInput: string;
|
|
28
|
+
/** The raw Ed25519 signature, 64 bytes when genuine. */
|
|
29
|
+
signature: Uint8Array;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Split a compact JWS, decode it, and validate both segments through their schemas.
|
|
34
|
+
*
|
|
35
|
+
* **Everything this returns is untrusted.** Parsing proves the token is well-formed; a forged token is
|
|
36
|
+
* well-formed too. No caller may read a claim, load a connection by `aud`, or act on `scope` before
|
|
37
|
+
* {@link verifyEd25519} has returned true for the `signingInput` and `signature` returned alongside
|
|
38
|
+
* them. The only exception is the `kid` lookup that finds the key to verify *with* — and that lookup
|
|
39
|
+
* grants nothing on its own.
|
|
40
|
+
*
|
|
41
|
+
* Every failure throws the one {@link ControlPlaneInvalidCredentialError}. Segment count, base64,
|
|
42
|
+
* JSON, schema — a caller cannot tell which, and so cannot use the response to find out how far a
|
|
43
|
+
* forgery got.
|
|
44
|
+
*/
|
|
45
|
+
export function parseCompactJws(token: string): ParsedCompactJws {
|
|
46
|
+
const segments = token.split(".");
|
|
47
|
+
const [encodedHeader, encodedClaims, encodedSignature] = segments;
|
|
48
|
+
if (segments.length !== 3 || !encodedHeader || !encodedClaims || !encodedSignature) {
|
|
49
|
+
throw new ControlPlaneInvalidCredentialError({
|
|
50
|
+
detail: `expected 3 non-empty compact JWS segments, got ${segments.length}`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
header: decodeSegment(encodedHeader, ControlPlaneJwsHeader, "header"),
|
|
56
|
+
claims: decodeSegment(encodedClaims, ControlPlaneClaims, "claims"),
|
|
57
|
+
// Verbatim, never re-serialized. Ed25519 signs bytes, and `JSON.stringify` of a parsed object is
|
|
58
|
+
// not reliably the bytes that were signed — key order and whitespace are the client's, not ours.
|
|
59
|
+
signingInput: `${encodedHeader}.${encodedClaims}`,
|
|
60
|
+
signature: base64UrlDecode(encodedSignature),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Decode one JSON segment and validate it, collapsing every failure into the one credential error. */
|
|
65
|
+
function decodeSegment<T>(segment: string, schema: z.ZodType<T>, label: string): T {
|
|
66
|
+
let json: unknown;
|
|
67
|
+
try {
|
|
68
|
+
json = JSON.parse(new TextDecoder().decode(base64UrlDecode(segment)));
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
// A bad segment already threw the right error from `base64UrlDecode`. Rethrow it rather than
|
|
71
|
+
// wrapping, so the detail keeps naming the step that actually failed.
|
|
72
|
+
if (cause instanceof ControlPlaneInvalidCredentialError) throw cause;
|
|
73
|
+
throw new ControlPlaneInvalidCredentialError({ detail: `token ${label} is not JSON` }, { cause });
|
|
74
|
+
}
|
|
75
|
+
const result = schema.safeParse(json);
|
|
76
|
+
if (!result.success) {
|
|
77
|
+
throw new ControlPlaneInvalidCredentialError({ detail: `token ${label} failed schema validation` });
|
|
78
|
+
}
|
|
79
|
+
return result.data;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verify an Ed25519 signature over the signing input with a registered public key.
|
|
84
|
+
*
|
|
85
|
+
* `false` rather than a throw is the contract, and it covers WebCrypto throwing too. An unimportable
|
|
86
|
+
* key means the adopter's connection row holds something malformed — that is a denial, not a Worker
|
|
87
|
+
* crash, and it must render as the same 401 a bad signature does. Turning a bad row into a 500 would
|
|
88
|
+
* both leak that the row is bad and take the route down for everyone.
|
|
89
|
+
*
|
|
90
|
+
* **The signature is copied before it is verified**, for the reason `sha256Base64Url` copies a body
|
|
91
|
+
* (#315): `crypto.subtle.verify` takes a `BufferSource`, which excludes a view onto a
|
|
92
|
+
* `SharedArrayBuffer`, and a bare `Uint8Array` does not exclude one. Under the DOM lib that was a type
|
|
93
|
+
* error in our source that an adopter's browser program could not fix; under Workers types it was a
|
|
94
|
+
* signature verified against bytes another thread could still be writing. Sixty-four bytes.
|
|
95
|
+
*/
|
|
96
|
+
export async function verifyEd25519(
|
|
97
|
+
signingInput: string,
|
|
98
|
+
signature: Uint8Array,
|
|
99
|
+
jwk: Ed25519PublicJwk,
|
|
100
|
+
): Promise<boolean> {
|
|
101
|
+
try {
|
|
102
|
+
const key = await crypto.subtle.importKey("jwk", jwk, { name: "Ed25519" }, false, ["verify"]);
|
|
103
|
+
return await crypto.subtle.verify(
|
|
104
|
+
"Ed25519",
|
|
105
|
+
key,
|
|
106
|
+
new Uint8Array(signature),
|
|
107
|
+
new TextEncoder().encode(signingInput),
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Ed25519PublicJwk } from "../data/connection";
|
|
5
|
+
import { base64UrlEncode } from "./base64url";
|
|
6
|
+
import { ControlPlaneClaims, type ControlPlaneJwsHeader } from "./claims";
|
|
7
|
+
import { sha256Base64Url } from "./digest";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mint a control-plane token — the management client's half of the seam.
|
|
11
|
+
*
|
|
12
|
+
* **This ships because the seam is MIT and is never gated by tier.** "You can build your own client
|
|
13
|
+
* against your own Worker" has to be true, and it is not true if the only way to produce a token the
|
|
14
|
+
* Worker accepts is to reverse-engineer the verifier. So the minting side lives here, beside the
|
|
15
|
+
* verifying side, and the two are tested against each other.
|
|
16
|
+
*
|
|
17
|
+
* It is also what the hosted dashboard would use, and what every test in this package uses to sign a
|
|
18
|
+
* real token rather than assert against a fixture.
|
|
19
|
+
*
|
|
20
|
+
* **Nothing here touches a private key store.** The caller supplies an already-imported `CryptoKey`.
|
|
21
|
+
* Core holds no key material, has no opinion about where a private key lives, and this file would be
|
|
22
|
+
* the wrong place to acquire one.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** One token's worth of claims, minus the parts {@link mintControlPlaneToken} computes. */
|
|
26
|
+
export interface MintControlPlaneToken {
|
|
27
|
+
/** The signing key, imported for `Ed25519` with `["sign"]` usage. */
|
|
28
|
+
privateKey: CryptoKey;
|
|
29
|
+
/** The `kid` naming which registered public key verifies this token. */
|
|
30
|
+
keyId: string;
|
|
31
|
+
/** The management client's origin — must equal the `issuer` stored on the connection. */
|
|
32
|
+
issuer: string;
|
|
33
|
+
/** The connection this token addresses. */
|
|
34
|
+
connectionId: string;
|
|
35
|
+
/** Who, in the management client's own id space, is acting. */
|
|
36
|
+
subject: string;
|
|
37
|
+
/** The single operation this token authorizes. One call, one scope. */
|
|
38
|
+
scope: string;
|
|
39
|
+
/** The raw request body these claims are bound to. Empty for a request that carries none. */
|
|
40
|
+
body?: Uint8Array;
|
|
41
|
+
/** Seconds this token is good for. The Worker caps it, so asking for more only wastes the request. */
|
|
42
|
+
lifetimeSeconds?: number;
|
|
43
|
+
/** The clock, injected so a caller can mint a token at a chosen instant. */
|
|
44
|
+
now?: () => Date;
|
|
45
|
+
/** The token's unique id. Generated when absent; supply one only to test a replay. */
|
|
46
|
+
tokenId?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Base64url a UTF-8 JSON serialization — the encoding both JWS segments use. */
|
|
50
|
+
function encodeSegment(value: unknown): string {
|
|
51
|
+
return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build and sign a compact JWS the seam will accept.
|
|
56
|
+
*
|
|
57
|
+
* The body digest is computed here rather than accepted from the caller, so a client cannot mint a
|
|
58
|
+
* token whose digest disagrees with the bytes it is about to send. `bodySha256` is null exactly when
|
|
59
|
+
* the body is empty, which is the same bijection the verifier enforces.
|
|
60
|
+
*/
|
|
61
|
+
export async function mintControlPlaneToken(input: MintControlPlaneToken): Promise<string> {
|
|
62
|
+
const body = input.body ?? new Uint8Array(0);
|
|
63
|
+
const issuedAt = Math.floor((input.now?.() ?? new Date()).getTime() / 1000);
|
|
64
|
+
|
|
65
|
+
const header: ControlPlaneJwsHeader = { alg: "EdDSA", typ: "JWT", kid: input.keyId };
|
|
66
|
+
const claims = ControlPlaneClaims.parse({
|
|
67
|
+
iss: input.issuer,
|
|
68
|
+
aud: input.connectionId,
|
|
69
|
+
sub: input.subject,
|
|
70
|
+
scope: input.scope,
|
|
71
|
+
jti: input.tokenId ?? crypto.randomUUID(),
|
|
72
|
+
iat: issuedAt,
|
|
73
|
+
exp: issuedAt + (input.lifetimeSeconds ?? 60),
|
|
74
|
+
bodySha256: body.byteLength === 0 ? null : await sha256Base64Url(body),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const signingInput = `${encodeSegment(header)}.${encodeSegment(claims)}`;
|
|
78
|
+
const signature = await crypto.subtle.sign("Ed25519", input.privateKey, new TextEncoder().encode(signingInput));
|
|
79
|
+
return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Export a generated key pair's public half as the JWK the adopter registers.
|
|
84
|
+
*
|
|
85
|
+
* `crv` and `kty` are re-asserted from our own constants rather than trusted from the export, so a
|
|
86
|
+
* runtime that spelled them differently produces a key this seam still recognizes — or fails here,
|
|
87
|
+
* where it is obvious, rather than at the first verification.
|
|
88
|
+
*/
|
|
89
|
+
export async function exportPublicJwk(publicKey: CryptoKey): Promise<Ed25519PublicJwk> {
|
|
90
|
+
const exported = (await crypto.subtle.exportKey("jwk", publicKey)) as { x?: string };
|
|
91
|
+
if (!exported.x) throw new TypeError("Ed25519 public key export carried no `x` coordinate.");
|
|
92
|
+
return { kty: "OKP", crv: "Ed25519", x: exported.x };
|
|
93
|
+
}
|