@vzn/vx-dte-server 0.0.0-stage → 0.0.649

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/src/oidc.ts ADDED
@@ -0,0 +1,307 @@
1
+ // CI OIDC tokens: RS256 JWTs a relay checks against each trusted issuer's
2
+ // published keys. The run claims bind every participant to the repository
3
+ // that started the run (PROTOCOL.md § Tokens). The JWT itself is jose's
4
+ // to check: signature, algorithm, audience and times.
5
+
6
+ import {
7
+ createLocalJWKSet,
8
+ decodeJwt,
9
+ decodeProtectedHeader,
10
+ errors,
11
+ jwtVerify,
12
+ type JWTPayload,
13
+ } from 'jose'
14
+
15
+ /**
16
+ * Who may take part: jobs of `workflow` in `repository`, by tokens of
17
+ * `issuer`. Unset parts admit any (`@vzn/vx-dte` checks a repository by its
18
+ * run claim); the relay's own entry points take all three (`parseAllow`).
19
+ */
20
+ export interface Allowed {
21
+ /** The full issuer URL, e.g. `https://token.actions.githubusercontent.com`. */
22
+ issuer: string
23
+ /** `owner/repo` (a GitLab project's full path), matched without case against `repositoryClaimOf(iss)`. */
24
+ repository?: string
25
+ /**
26
+ * The workflow file its jobs are defined in, any ref, matched without case
27
+ * against `workflowClaimOf(iss)`: on GitHub a file of `.github/workflows/`
28
+ * (`ci.yml`), on GitLab the CI config's path (`.gitlab-ci.yml`).
29
+ */
30
+ workflow?: string
31
+ }
32
+
33
+ export interface OidcOptions {
34
+ /** Whose tokens the relay takes; any other is refused. */
35
+ allow: readonly Allowed[]
36
+ /** The `aud` a token must name. */
37
+ audience: string
38
+ /** Run claims by issuer; an issuer not named here uses `runClaimsOf`. */
39
+ runClaims?: Readonly<Record<string, readonly string[]>>
40
+ /**
41
+ * Reads a URL as JSON; tests serve keys without a network. `fresh`: a
42
+ * token named a key the last read did not have, so a cached copy will not do.
43
+ */
44
+ fetchJson?: (url: string, fresh: boolean) => Promise<unknown>
45
+ /** Seconds since the epoch; tests move the clock. */
46
+ now?: () => number
47
+ }
48
+
49
+ /** A token the relay accepted: its issuer and the values of its run claims. */
50
+ export interface VerifiedToken {
51
+ iss: string
52
+ run: readonly string[]
53
+ }
54
+
55
+ export class TokenError extends Error {
56
+ constructor(
57
+ message: string,
58
+ readonly status: 401 | 403 | 502,
59
+ ) {
60
+ super(message)
61
+ }
62
+ }
63
+
64
+ const GITHUB = 'https://token.actions.githubusercontent.com'
65
+
66
+ /**
67
+ * The claims a run binds: its repository, GitHub's or GitLab's. Not the
68
+ * pipeline: workers run in another one (a GitLab pipeline the run triggers,
69
+ * a GitHub workflow it dispatches), and the run's own key admits them.
70
+ */
71
+ export function runClaimsOf(iss: string): readonly string[] {
72
+ return iss === GITHUB ? ['repository_id'] : ['project_id']
73
+ }
74
+
75
+ /**
76
+ * The claim naming a token's repository: `owner/repo` on GitHub, the
77
+ * project's full path on GitLab.
78
+ */
79
+ export function repositoryClaimOf(iss: string): string {
80
+ return iss === GITHUB ? 'repository' : 'project_path'
81
+ }
82
+
83
+ /**
84
+ * The claim naming the workflow a token's job is defined in, `<path>@<ref>`:
85
+ * GitHub's `job_workflow_ref` (the reusable workflow, when the job is one),
86
+ * GitLab's `ci_config_ref_uri`.
87
+ */
88
+ export function workflowClaimOf(iss: string): string {
89
+ return iss === GITHUB ? 'job_workflow_ref' : 'ci_config_ref_uri'
90
+ }
91
+
92
+ /**
93
+ * `ALLOW`'s entries, space separated, each
94
+ * `issuer:owner/repo:workflow`: read from the right, since the issuer
95
+ * holds `:`s of its own.
96
+ */
97
+ export function parseAllow(list: string): Allowed[] {
98
+ return list
99
+ .split(/\s+/)
100
+ .filter((e) => e !== '')
101
+ .map((e) => {
102
+ // One grammar on every server: read as a separator nowhere, a comma
103
+ // would end up inside a workflow name no token ever carries.
104
+ if (e.includes(','))
105
+ throw new Error(`${e} is not issuer:owner/repo:workflow: entries are space separated`)
106
+ const w = e.lastIndexOf(':')
107
+ const r = e.lastIndexOf(':', w - 1)
108
+ const [issuer, repository, workflow] = [e.slice(0, r), e.slice(r + 1, w), e.slice(w + 1)]
109
+ // An entry missing a part splits elsewhere: its issuer is then no URL.
110
+ const url = /^[a-z][a-z0-9+.-]*:\/\/[^/]/i.test(issuer)
111
+ if (r < 0 || !url || !repository.includes('/') || workflow === '') {
112
+ throw new Error(`${e} is not issuer:owner/repo:workflow`)
113
+ }
114
+ // @vzn/vx-devtools' rule, which the cache and history servers read
115
+ // ALLOW with (the relay imports only jose): plain http only to
116
+ // 127.0.0.1's host, where a container's tests run an issuer. Its
117
+ // host, not its spelling: `http://127.0.0.1:80@x.example` is x.example.
118
+ const https =
119
+ issuer.startsWith('https://') ||
120
+ (issuer.startsWith('http://') && URL.parse(issuer)?.hostname === '127.0.0.1')
121
+ if (!https)
122
+ throw new Error(
123
+ `${e}: the issuer is https://, not ${issuer} (plain http only on 127.0.0.1)`,
124
+ )
125
+ // A token's iss has no trailing slash: one written in ALLOW matched none.
126
+ return { issuer: issuer.replace(/\/+$/, ''), repository, workflow }
127
+ })
128
+ }
129
+
130
+ /** What `workflowClaimOf(iss)` says, up to its `@`, for a job of `workflow` in `repository`. */
131
+ function workflowPath(iss: string, repository: string, workflow: string): string {
132
+ return iss === GITHUB
133
+ ? `${repository}/.github/workflows/${workflow}`
134
+ : `${iss.replace(/^https?:\/\//, '').replace(/\/$/, '')}/${repository}//${workflow}`
135
+ }
136
+
137
+ const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase()
138
+
139
+ /** Clock skew a token's `exp` / `nbf` may be off by. */
140
+ const LEEWAY_S = 60
141
+ /** An RS256 signature: at least a 2048-bit key's 256 bytes, base64url. */
142
+ const SIGNATURE = /^[\w-]{342,}$/
143
+
144
+ /** An unknown `kid` refetches an issuer's keys at most this often. */
145
+ const REFETCH_MS = 60_000
146
+ /**
147
+ * Keys read longer ago than this are read again: a key the issuer withdrew
148
+ * stops signing for the relay, as jose's remote key set ages them for the
149
+ * cache and history servers.
150
+ */
151
+ const KEYS_MAX_AGE_MS = 600_000
152
+
153
+ export function createVerifier(opts: OidcOptions): (token: string) => Promise<VerifiedToken> {
154
+ const fetchJson = opts.fetchJson ?? defaultFetchJson
155
+ const now = opts.now ?? (() => Date.now() / 1000)
156
+ const nowMs = () => now() * 1000
157
+ type KeySet = ReturnType<typeof createLocalJWKSet>
158
+ const keys = new Map<string, { at: number; set: Promise<KeySet> }>()
159
+
160
+ const load = (iss: string, fresh: boolean): { at: number; set: Promise<KeySet> } => {
161
+ const entry = { at: nowMs(), set: loadKeys(iss, (url) => fetchJson(url, fresh)) }
162
+ keys.set(iss, entry)
163
+ // A failed load is not kept: the next token tries again.
164
+ // quiet: the awaited set rethrows it; this only forgets it
165
+ entry.set.catch(() => keys.get(iss) === entry && keys.delete(iss))
166
+ return entry
167
+ }
168
+ const verifyWith = async (token: string, iss: string, fresh: boolean): Promise<JWTPayload> => {
169
+ const held = fresh ? undefined : keys.get(iss)
170
+ const entry =
171
+ held !== undefined && nowMs() - held.at < KEYS_MAX_AGE_MS ? held : load(iss, fresh)
172
+ // The issuer's outage, not the token's fault: a client ends its run on
173
+ // a 401, and retries a 5xx.
174
+ const set = await entry.set.catch((err: unknown) => {
175
+ throw keysUnread(iss, err)
176
+ })
177
+ const { payload } = await jwtVerify(token, set, {
178
+ algorithms: ['RS256'],
179
+ audience: opts.audience,
180
+ clockTolerance: LEEWAY_S,
181
+ currentDate: new Date(now() * 1000),
182
+ requiredClaims: ['exp'],
183
+ })
184
+ return payload
185
+ }
186
+
187
+ return async (token) => {
188
+ let iss: unknown
189
+ let alg: unknown
190
+ try {
191
+ iss = decodeJwt(token).iss
192
+ // decodeJwt reads no header: one that does not decode threw out of the relay (a 500).
193
+ alg = decodeProtectedHeader(token).alg
194
+ // A token that cannot be signed costs no fetch of its issuer's keys.
195
+ if (!SIGNATURE.test(token.slice(token.lastIndexOf('.') + 1))) throw new Error()
196
+ } catch {
197
+ throw new TokenError('not a JWT', 401)
198
+ }
199
+ if (alg !== 'RS256') throw new TokenError('alg is not RS256', 401)
200
+ const ofIssuer = opts.allow.filter((a) => a.issuer === iss)
201
+ if (typeof iss !== 'string' || ofIssuer.length === 0) {
202
+ throw new TokenError(
203
+ typeof iss === 'string'
204
+ ? `issuer ${iss.slice(0, 200)} is not in this relay's ALLOW: add issuer:owner/repo:workflow for it`
205
+ : 'the token names no issuer (iss)',
206
+ 401,
207
+ )
208
+ }
209
+ let claims: JWTPayload
210
+ try {
211
+ // A key rotated in since the last read is fetched once a REFETCH_MS.
212
+ claims = await verifyWith(token, iss, false).catch((err: unknown) => {
213
+ const at = keys.get(iss)?.at ?? 0
214
+ if (!(err instanceof errors.JWKSNoMatchingKey) || nowMs() - at < REFETCH_MS) throw err
215
+ return verifyWith(token, iss, true)
216
+ })
217
+ } catch (err) {
218
+ throw refusal(err, iss, opts.audience)
219
+ }
220
+ let ofRepo = ofIssuer
221
+ if (ofIssuer.some((a) => a.repository !== undefined)) {
222
+ const claim = repositoryClaimOf(iss)
223
+ const repo = claims[claim]
224
+ if (typeof repo !== 'string') throw new TokenError(`no ${claim} claim`, 403)
225
+ ofRepo = ofIssuer.filter((a) => a.repository === undefined || same(a.repository, repo))
226
+ if (ofRepo.length === 0) {
227
+ throw new TokenError(`repository ${repo} is not in this relay's ALLOW`, 403)
228
+ }
229
+ }
230
+ if (ofRepo.every((a) => a.workflow !== undefined)) {
231
+ const claim = workflowClaimOf(iss)
232
+ const ref = claims[claim]
233
+ if (typeof ref !== 'string') throw new TokenError(`no ${claim} claim`, 403)
234
+ const repo = String(claims[repositoryClaimOf(iss)])
235
+ // The path is the claim up to an `@` that a ref follows: a file name may hold `@` too.
236
+ const named = ofRepo.some((a) => {
237
+ const p = `${workflowPath(iss, a.repository ?? repo, a.workflow!)}@`
238
+ return same(ref.slice(0, p.length), p)
239
+ })
240
+ if (!named) {
241
+ throw new TokenError(
242
+ `workflow ${ref.replace(/@.*$/, '')} is not in this relay's ALLOW`,
243
+ 403,
244
+ )
245
+ }
246
+ }
247
+ const run = (opts.runClaims?.[iss] ?? runClaimsOf(iss)).map((c) => {
248
+ const v = claims[c]
249
+ if (typeof v !== 'string' && typeof v !== 'number') {
250
+ throw new TokenError(`no ${c} claim`, 403)
251
+ }
252
+ return String(v)
253
+ })
254
+ return { iss, run }
255
+ }
256
+ }
257
+
258
+ /** What jose's refusal of a token says, as the relay answers it. */
259
+ function refusal(err: unknown, iss: string, audience: string): TokenError {
260
+ if (err instanceof TokenError) return err
261
+ // A refetch of the keys for a kid not held failed: the network, not the token.
262
+ if (!(err instanceof errors.JOSEError) || err instanceof errors.JWKSInvalid)
263
+ return keysUnread(iss, err)
264
+ if (err instanceof errors.JOSEAlgNotAllowed) return new TokenError('alg is not RS256', 401)
265
+ if (err instanceof errors.JWKSNoMatchingKey) return new TokenError('unknown signing key', 401)
266
+ if (err instanceof errors.JWSSignatureVerificationFailed)
267
+ return new TokenError('bad signature', 401)
268
+ if (err instanceof errors.JWTExpired) return new TokenError('expired', 401)
269
+ if (err instanceof errors.JWTClaimValidationFailed) {
270
+ if (err.claim === 'aud')
271
+ return new TokenError(
272
+ `the token's aud is ${[err.payload.aud ?? 'unset'].flat().join(', ')}, not this relay's ${audience}: ask CI for a token for ${audience}, or set the relay's audience`,
273
+ 401,
274
+ )
275
+ if (err.claim === 'nbf') return new TokenError('not yet valid', 401)
276
+ if (err.claim === 'exp')
277
+ return new TokenError(err.reason === 'missing' ? 'no exp claim' : 'expired', 401)
278
+ }
279
+ return new TokenError('not a JWT', 401)
280
+ }
281
+
282
+ async function loadKeys(
283
+ iss: string,
284
+ fetchJson: (url: string) => Promise<unknown>,
285
+ ): Promise<ReturnType<typeof createLocalJWKSet>> {
286
+ const config = (await fetchJson(
287
+ `${iss.replace(/\/$/, '')}/.well-known/openid-configuration`,
288
+ )) as {
289
+ jwks_uri?: unknown
290
+ }
291
+ if (typeof config.jwks_uri !== 'string') throw new Error('no jwks_uri')
292
+ const jwks = (await fetchJson(config.jwks_uri)) as { keys?: unknown }
293
+ return createLocalJWKSet({ keys: Array.isArray(jwks.keys) ? jwks.keys : [] })
294
+ }
295
+
296
+ /** The issuer's keys out of reach, with why: a 503 and a missing jwks_uri read the same. */
297
+ const keysUnread = (iss: string, err: unknown): TokenError =>
298
+ new TokenError(
299
+ `could not read ${iss}'s keys: ${err instanceof Error ? err.message : String(err)}`,
300
+ 502,
301
+ )
302
+
303
+ async function defaultFetchJson(url: string): Promise<unknown> {
304
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
305
+ if (!res.ok) throw new Error(`${url}: ${res.status}`)
306
+ return res.json()
307
+ }
package/src/proof.ts ADDED
@@ -0,0 +1,82 @@
1
+ // The orchestrator's key (PROTOCOL.md "The orchestrator's key"): its join
2
+ // names a P-256 public key the relay keeps for the run, and each later
3
+ // request as the orchestrator carries a signature by it, so only the side
4
+ // holding the private key acts as the run's orchestrator.
5
+
6
+ const ALG = { name: 'ECDSA', namedCurve: 'P-256' } as const
7
+ const SIGN = { name: 'ECDSA', hash: 'SHA-256' } as const
8
+ const text = new TextEncoder()
9
+
10
+ /** The header a request as the host proves itself in. */
11
+ export const PROOF_HEADER = 'relay-proof'
12
+
13
+ /** What a proof signs: the request, when, and a nonce the relay sees once. */
14
+ export function proofText(
15
+ method: string,
16
+ path: string,
17
+ ts: number,
18
+ nonce: string,
19
+ body: string,
20
+ ): Uint8Array<ArrayBuffer> {
21
+ return text.encode(`switchboard-proof\0${method}\0${path}\0${ts}\0${nonce}\0${body}`)
22
+ }
23
+
24
+ /** A run's orchestrator key: `key` its join names, `proof` the header of each request. */
25
+ export interface Prover {
26
+ /** The public key, raw and base64url. */
27
+ readonly key: string
28
+ /** `path` is the request URL's path and query. */
29
+ proof(method: string, path: string, body: string): Promise<string>
30
+ }
31
+
32
+ /** A new key for one run; it never leaves this process. */
33
+ export async function createProver(): Promise<Prover> {
34
+ const pair = (await crypto.subtle.generateKey(ALG, false, ['sign', 'verify'])) as CryptoKeyPair
35
+ const key = b64u(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)))
36
+ return {
37
+ key,
38
+ async proof(method, path, body) {
39
+ const ts = Date.now()
40
+ const nonce = b64u(crypto.getRandomValues(new Uint8Array(16)))
41
+ const sig = await crypto.subtle.sign(
42
+ SIGN,
43
+ pair.privateKey,
44
+ proofText(method, path, ts, nonce, body),
45
+ )
46
+ return `${ts}.${nonce}.${b64u(new Uint8Array(sig))}`
47
+ },
48
+ }
49
+ }
50
+
51
+ /** A key a join named; rejects one that is not a P-256 point. */
52
+ export async function importKey(raw: string): Promise<CryptoKey> {
53
+ return crypto.subtle.importKey('raw', unb64u(raw), ALG, false, ['verify'])
54
+ }
55
+
56
+ export async function verifyProof(
57
+ key: CryptoKey,
58
+ sig: string,
59
+ signed: Uint8Array<ArrayBuffer>,
60
+ ): Promise<boolean> {
61
+ // A signature that is not base64url is a bad one: atob threw out of the relay (a 500).
62
+ try {
63
+ return await crypto.subtle.verify(SIGN, key, unb64u(sig), signed)
64
+ } catch {
65
+ // quiet: a signature that cannot be read is a bad one: false is the answer
66
+ return false
67
+ }
68
+ }
69
+
70
+ // No Buffer: a Worker has none.
71
+ function b64u(b: Uint8Array): string {
72
+ let s = ''
73
+ for (const c of b) s += String.fromCharCode(c)
74
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
75
+ }
76
+
77
+ function unb64u(s: string): Uint8Array<ArrayBuffer> {
78
+ const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'))
79
+ const out = new Uint8Array(bin.length)
80
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
81
+ return out
82
+ }