@coffre/core 0.1.15 → 0.1.17

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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Trust bindings: which CI runs may sign in as a service, by the ID token
3
+ * their platform signs for them (docs/design/oidc.md).
4
+ *
5
+ * A binding names a profile, an issuer, and claims a token must carry, each
6
+ * a top-level string matched exactly. The profile sets the minimum: the
7
+ * server refuses a binding that lacks a claim its profile requires, from the
8
+ * UI, the CLI or the API alike. Say a service is bound to `deploy.yml`,
9
+ * pushed to `main` of `acme/api`:
10
+ *
11
+ * { profile: 'github', issuer: 'https://token.actions.githubusercontent.com',
12
+ * claims: { repository_owner_id: '9919', repository_id: '41532',
13
+ * workflow_ref: 'acme/api/.github/workflows/deploy.yml@refs/heads/main',
14
+ * ref: 'refs/heads/main', event_name: 'push' } }
15
+ */
16
+
17
+ export const GITHUB_ISSUER = 'https://token.actions.githubusercontent.com';
18
+ export const GITLAB_ISSUER = 'https://gitlab.com';
19
+
20
+ /**
21
+ * - `github`: a workflow of the repository.
22
+ * - `github-reusable`: a reusable workflow, pinned to one commit, called from
23
+ * one repository at one ref.
24
+ * - `github-reusable-organization`: the same, called from any repository of
25
+ * the organization: an explicit choice, never what a missing claim means.
26
+ * - `gitlab`: a project's pipelines, outside merge requests.
27
+ * - `custom`: any other issuer, by its subject, which the owner vouches for.
28
+ */
29
+ export type WorkloadProfile = 'github' | 'github-reusable' | 'github-reusable-organization' | 'gitlab' | 'custom';
30
+
31
+ export const WORKLOAD_PROFILES: readonly WorkloadProfile[] = [
32
+ 'github',
33
+ 'github-reusable',
34
+ 'github-reusable-organization',
35
+ 'gitlab',
36
+ 'custom',
37
+ ];
38
+
39
+ /** The claims each profile requires; a binding may add more, to narrow it. */
40
+ export const REQUIRED_CLAIMS: Record<WorkloadProfile, readonly string[]> = {
41
+ github: ['repository_owner_id', 'repository_id', 'workflow_ref', 'ref', 'event_name'],
42
+ 'github-reusable': ['repository_owner_id', 'repository_id', 'ref', 'event_name', 'job_workflow_ref', 'job_workflow_sha'],
43
+ 'github-reusable-organization': ['repository_owner_id', 'ref', 'event_name', 'job_workflow_ref', 'job_workflow_sha'],
44
+ gitlab: ['namespace_id', 'project_id', 'ref_type', 'ref', 'pipeline_source'],
45
+ custom: ['sub'],
46
+ };
47
+
48
+ /**
49
+ * Events triggered by someone with write access, at a ref the binding names
50
+ * exactly. `pull_request`, `pull_request_target`, `workflow_run` and the
51
+ * rest are refused, even when their ref happens to match: their runs can
52
+ * carry a stranger's code.
53
+ */
54
+ export const GITHUB_EVENTS = ['push', 'workflow_dispatch', 'schedule', 'release'] as const;
55
+
56
+ /** Pipelines that run the project's own code, at its own ref: never a merge request's. */
57
+ export const GITLAB_PIPELINE_SOURCES = ['push', 'web', 'schedule'] as const;
58
+
59
+ /** At most this many live bindings per service, and claims per binding. */
60
+ export const MAX_BINDINGS = 16;
61
+ export const MAX_CLAIMS = 16;
62
+ /** The longest claim value, in bytes. */
63
+ export const MAX_CLAIM_BYTES = 256;
64
+
65
+ /** Claims a binding never names: the issuer is its own field, and the audience, times and ID are the exchange's. */
66
+ const RESERVED = new Set(['iss', 'aud', 'exp', 'iat', 'nbf', 'jti']);
67
+ const CLAIM_NAME = /^[A-Za-z0-9_.:/-]{1,64}$/;
68
+ const DIGITS = /^[1-9][0-9]{0,19}$/;
69
+ const SHA = /^[0-9a-f]{40}$/;
70
+ const REF = /^refs\/(heads|tags)\/\S{1,200}$/;
71
+ const WORKFLOW = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[^@\s]+\.ya?ml@\S+$/;
72
+
73
+ export type BindingClaims = Record<string, string>;
74
+
75
+ /** What makes a binding, checked: its profile, its issuer as a URL, and its claims, sorted. */
76
+ export type BindingPolicy = { profile: WorkloadProfile; issuer: string; claims: BindingClaims };
77
+
78
+ /** A binding that breaks a rule, said so an owner can fix it. */
79
+ export class BindingInvalid extends Error {
80
+ constructor(message: string) {
81
+ super(message);
82
+ this.name = 'BindingInvalid';
83
+ }
84
+ }
85
+
86
+ /** The profile an issuer must use, for the issuers coffre knows: github.com's for GitHub, gitlab.com's for GitLab. */
87
+ function forcedProfiles(issuer: string): readonly WorkloadProfile[] | null {
88
+ if (issuer === GITHUB_ISSUER) return ['github', 'github-reusable', 'github-reusable-organization'];
89
+ if (issuer === GITLAB_ISSUER) return ['gitlab'];
90
+ return null;
91
+ }
92
+
93
+ /** The issuer a profile means when none is given: github.com's or gitlab.com's. A GitHub Enterprise Server or a self-managed GitLab names its own. */
94
+ export function defaultIssuer(profile: WorkloadProfile): string | null {
95
+ if (profile === 'gitlab') return GITLAB_ISSUER;
96
+ return profile === 'custom' ? null : GITHUB_ISSUER;
97
+ }
98
+
99
+ /**
100
+ * Check a binding against its profile, and return it as stored: the
101
+ * issuer as given, the claims sorted by name. Throws `BindingInvalid`.
102
+ */
103
+ export function checkBinding(
104
+ input: { profile: string; issuer: string | null; claims: Record<string, unknown> },
105
+ options: { allowLoopback?: boolean } = {},
106
+ ): BindingPolicy {
107
+ const profile = input.profile as WorkloadProfile;
108
+ if (!WORKLOAD_PROFILES.includes(profile)) {
109
+ throw new BindingInvalid(`unknown profile "${input.profile}": one of ${WORKLOAD_PROFILES.join(', ')}`);
110
+ }
111
+ const issuer = input.issuer ?? defaultIssuer(profile);
112
+ if (issuer === null) throw new BindingInvalid('a custom binding names its issuer');
113
+ checkFetchUrl(issuer, 'the issuer', { allowLoopback: options.allowLoopback, path: true, query: false });
114
+ const forced = forcedProfiles(issuer);
115
+ if (forced !== null && !forced.includes(profile)) {
116
+ throw new BindingInvalid(`${issuer} signs only for the ${forced[0]} profile${forced.length > 1 ? 's' : ''}`);
117
+ }
118
+
119
+ const entries = Object.entries(input.claims);
120
+ if (entries.length > MAX_CLAIMS) throw new BindingInvalid(`a binding names at most ${MAX_CLAIMS} claims`);
121
+ const claims: BindingClaims = {};
122
+ for (const [name, value] of entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
123
+ if (!CLAIM_NAME.test(name)) throw new BindingInvalid(`"${name}" is not a claim name a binding can match`);
124
+ if (RESERVED.has(name)) throw new BindingInvalid(`a binding never names "${name}": the exchange checks it`);
125
+ if (typeof value !== 'string' || value.length === 0) throw new BindingInvalid(`claim ${name} must be a nonempty string`);
126
+ if (new TextEncoder().encode(value).length > MAX_CLAIM_BYTES) {
127
+ throw new BindingInvalid(`claim ${name} is longer than ${MAX_CLAIM_BYTES} bytes`);
128
+ }
129
+ claims[name] = value;
130
+ }
131
+ const missing = REQUIRED_CLAIMS[profile].filter((name) => claims[name] === undefined);
132
+ if (missing.length > 0) throw new BindingInvalid(`the ${profile} profile requires ${missing.join(', ')}`);
133
+ if (profile === 'github-reusable-organization' && claims.repository_id !== undefined) {
134
+ throw new BindingInvalid('a binding for any repository of the organization names no repository_id: use github-reusable');
135
+ }
136
+
137
+ if (profile.startsWith('github')) checkGitHub(profile, claims);
138
+ if (profile === 'gitlab') checkGitLab(claims);
139
+ return { profile, issuer, claims };
140
+ }
141
+
142
+ function checkGitHub(profile: WorkloadProfile, claims: BindingClaims): void {
143
+ for (const id of ['repository_owner_id', 'repository_id']) {
144
+ if (claims[id] !== undefined && !DIGITS.test(claims[id])) throw new BindingInvalid(`${id} is GitHub's numeric ID, not a name`);
145
+ }
146
+ const event = claims.event_name;
147
+ if (!(GITHUB_EVENTS as readonly string[]).includes(event)) {
148
+ throw new BindingInvalid(`event_name must be one of ${GITHUB_EVENTS.join(', ')}: other events can run a stranger's code`);
149
+ }
150
+ const ref = claims.ref;
151
+ if (!REF.test(ref)) throw new BindingInvalid('ref is a full ref: refs/heads/<branch> or refs/tags/<tag>');
152
+ if (event === 'release' && !ref.startsWith('refs/tags/')) throw new BindingInvalid('a release runs at its tag: refs/tags/<tag>');
153
+ if (event === 'schedule' && !ref.startsWith('refs/heads/')) throw new BindingInvalid('a schedule runs on the default branch: refs/heads/<branch>');
154
+ if (profile === 'github') {
155
+ // The workflow file at the ref it ran from, which for these events is the run's ref.
156
+ if (!WORKFLOW.test(claims.workflow_ref) || !claims.workflow_ref.endsWith(`@${ref}`)) {
157
+ throw new BindingInvalid('workflow_ref is <owner>/<repository>/.github/workflows/<file>@<ref>, at the binding\'s ref');
158
+ }
159
+ return;
160
+ }
161
+ // A reusable workflow, pinned to one commit: its path can be reused and its branch changed.
162
+ if (!WORKFLOW.test(claims.job_workflow_ref)) {
163
+ throw new BindingInvalid('job_workflow_ref is <owner>/<repository>/.github/workflows/<file>@<ref>');
164
+ }
165
+ if (!SHA.test(claims.job_workflow_sha)) throw new BindingInvalid('job_workflow_sha is the called workflow\'s full commit SHA');
166
+ if (claims.workflow_ref !== undefined && !WORKFLOW.test(claims.workflow_ref)) {
167
+ throw new BindingInvalid('workflow_ref, which narrows the binding to one caller, is <owner>/<repository>/.github/workflows/<file>@<ref>');
168
+ }
169
+ }
170
+
171
+ function checkGitLab(claims: BindingClaims): void {
172
+ for (const id of ['namespace_id', 'project_id']) {
173
+ if (!DIGITS.test(claims[id])) throw new BindingInvalid(`${id} is GitLab's numeric ID, not a path`);
174
+ }
175
+ if (claims.ref_type !== 'branch' && claims.ref_type !== 'tag') throw new BindingInvalid('ref_type is branch or tag');
176
+ if (claims.ref.startsWith('refs/')) throw new BindingInvalid('GitLab\'s ref is the branch or tag name, without refs/');
177
+ if (!(GITLAB_PIPELINE_SOURCES as readonly string[]).includes(claims.pipeline_source)) {
178
+ throw new BindingInvalid(`pipeline_source must be one of ${GITLAB_PIPELINE_SOURCES.join(', ')}: merge-request pipelines name another project`);
179
+ }
180
+ }
181
+
182
+ /** The claims as the MAC and the database hold them: JSON, sorted by name. */
183
+ export function canonicalClaims(claims: BindingClaims): string {
184
+ return JSON.stringify(Object.fromEntries(Object.entries(claims).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))));
185
+ }
186
+
187
+ /**
188
+ * A URL coffre may fetch for a binding: an issuer, or the keys its
189
+ * discovery names. `https` on port 443, a host name rather than an address,
190
+ * and nothing that names this machine or its network. Loopback over plain
191
+ * HTTP only where the deployment allows it, for the dev IdP. Where the name
192
+ * resolves is checked again when connecting, on Node (`addresses.ts` in the
193
+ * server).
194
+ */
195
+ export function checkFetchUrl(
196
+ value: string,
197
+ what: string,
198
+ options: { allowLoopback?: boolean; path: boolean; query: boolean },
199
+ ): URL {
200
+ let url: URL;
201
+ try {
202
+ url = new URL(value);
203
+ } catch {
204
+ throw new BindingInvalid(`${what} is not a URL`);
205
+ }
206
+ if (url.username !== '' || url.password !== '') throw new BindingInvalid(`${what} carries no user or password`);
207
+ if (url.hash !== '' || (!options.query && url.search !== '')) throw new BindingInvalid(`${what} has no ${options.query ? 'fragment' : 'query or fragment'}`);
208
+ if (!options.path && url.pathname !== '/') throw new BindingInvalid(`${what} has no path`);
209
+ // Tokens name their issuer exactly, so it is stored as written, written as a URL is.
210
+ if (url.href !== value && url.href !== `${value}/`) throw new BindingInvalid(`${what} must be written as a plain URL: ${url.href}`);
211
+ const host = url.hostname;
212
+ const loopback = host === '127.0.0.1' || host === 'localhost' || host === '[::1]';
213
+ if (options.allowLoopback === true && loopback) return url;
214
+ if (url.protocol !== 'https:') throw new BindingInvalid(`${what} must use https`);
215
+ if (url.port !== '' && url.port !== '443') throw new BindingInvalid(`${what} must use port 443`);
216
+ if (/^[0-9.]+$/.test(host) || host.startsWith('[')) throw new BindingInvalid(`${what} names a host, not an IP address`);
217
+ if (!host.includes('.') || /(^|\.)(localhost|local|internal|localdomain|home\.arpa)$/.test(host)) {
218
+ throw new BindingInvalid(`${what} must be a public host name`);
219
+ }
220
+ return url;
221
+ }
222
+
223
+ // --- presets -----------------------------------------------------------------
224
+ //
225
+ // What the UI's forms and the CLI's flags ask for, and the claims they fill
226
+ // in. They decide nothing: `checkBinding`, on the server, holds the result
227
+ // to its profile, whoever built it.
228
+
229
+ /** `refs/heads/main`, or `refs/tags/v1`, as GitHub names a ref. */
230
+ export function fullRef(kind: 'branch' | 'tag', name: string): string {
231
+ return `refs/${kind === 'branch' ? 'heads' : 'tags'}/${name}`;
232
+ }
233
+
234
+ /** A workflow file at a ref, as GitHub's tokens name it: `acme/api/.github/workflows/deploy.yml@refs/heads/main`. */
235
+ export function workflowRef(repository: string, file: string, ref: string): string {
236
+ return `${repository}/.github/workflows/${file.replace(/^\.github\/workflows\//, '')}@${ref}`;
237
+ }
238
+
239
+ /** A workflow of the repository, run by `event` at `ref`. */
240
+ export function githubWorkflow(input: {
241
+ repository: string;
242
+ repositoryId: string;
243
+ ownerId: string;
244
+ workflow: string;
245
+ ref: string;
246
+ event: string;
247
+ }): Omit<BindingPolicy, 'issuer'> {
248
+ return {
249
+ profile: 'github',
250
+ claims: {
251
+ repository_owner_id: input.ownerId,
252
+ repository_id: input.repositoryId,
253
+ workflow_ref: workflowRef(input.repository, input.workflow, input.ref),
254
+ ref: input.ref,
255
+ event_name: input.event,
256
+ },
257
+ };
258
+ }
259
+
260
+ /**
261
+ * A reusable workflow at one commit, called at `ref`: from one repository,
262
+ * or, with `repositoryId` null, from any repository of the organization. A
263
+ * `caller` workflow narrows it to one calling workflow.
264
+ */
265
+ export function githubReusable(input: {
266
+ repositoryId: string | null;
267
+ ownerId: string;
268
+ ref: string;
269
+ event: string;
270
+ called: string;
271
+ sha: string;
272
+ caller?: { repository: string; workflow: string };
273
+ }): Omit<BindingPolicy, 'issuer'> {
274
+ const claims: BindingClaims = {
275
+ repository_owner_id: input.ownerId,
276
+ ...(input.repositoryId === null ? {} : { repository_id: input.repositoryId }),
277
+ ref: input.ref,
278
+ event_name: input.event,
279
+ job_workflow_ref: input.called,
280
+ job_workflow_sha: input.sha,
281
+ };
282
+ if (input.caller !== undefined) claims.workflow_ref = workflowRef(input.caller.repository, input.caller.workflow, input.ref);
283
+ return { profile: input.repositoryId === null ? 'github-reusable-organization' : 'github-reusable', claims };
284
+ }
285
+
286
+ /** A GitLab project's pipelines from `source`, at a branch or tag. */
287
+ export function gitlabProject(input: {
288
+ namespaceId: string;
289
+ projectId: string;
290
+ refType: 'branch' | 'tag';
291
+ ref: string;
292
+ source: string;
293
+ }): Omit<BindingPolicy, 'issuer'> {
294
+ return {
295
+ profile: 'gitlab',
296
+ claims: {
297
+ namespace_id: input.namespaceId,
298
+ project_id: input.projectId,
299
+ ref_type: input.refType,
300
+ ref: input.ref,
301
+ pipeline_source: input.source,
302
+ },
303
+ };
304
+ }
package/src/vault.ts CHANGED
@@ -173,6 +173,12 @@ type Correlation = {
173
173
  * reads, a write's versions, an access change. Its entries share it.
174
174
  */
175
175
  operationId?: string | null;
176
+ /**
177
+ * The credential the request came in on, when a trust binding issued it
178
+ * for a CI run: the vault copies it into the entries it writes, and never
179
+ * decides on it, so that a read leads back to its run (docs/design/oidc.md).
180
+ */
181
+ credentialId?: string | null;
176
182
  };
177
183
 
178
184
  export type UnwrapInput = Correlation & {