@coffre/core 0.6.2 → 0.8.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.
@@ -4,10 +4,11 @@
4
4
  * their platform signs for them (docs/design/oidc.md).
5
5
  *
6
6
  * A binding names a profile, an issuer, and claims a token must carry, each
7
- * a top-level string matched exactly. The event that started the run may
8
- * be any of several: a list. The profile sets the minimum: the server
9
- * refuses a binding that lacks a claim its profile requires, from the UI,
10
- * the CLI or the API alike. Say a service is bound to `ci.yml` of
7
+ * a string matched exactly, at the top of the token or nested in it
8
+ * (`claimAt`). The event that started the run may be any of several: a
9
+ * list. The profile sets the minimum: the server refuses a binding that
10
+ * lacks a claim its profile requires, from the UI, the CLI or the API
11
+ * alike. Say a service is bound to `ci.yml` of
11
12
  * `acme/api`, pushed or dispatched on `main`, or run for a pull request
12
13
  * into `main`:
13
14
  *
@@ -72,7 +73,12 @@ export declare const MAX_CLAIM_BYTES = 256;
72
73
  /** A claim a binding names: one value, or, for what started the run, any of several. */
73
74
  export type ClaimValue = string | string[];
74
75
  export type BindingClaims = Record<string, ClaimValue>;
75
- /** What makes a binding, checked: its profile, its issuer as a URL, and its claims, sorted. */
76
+ /**
77
+ * What makes a binding, checked: its profile, its issuer, and its claims,
78
+ * sorted. The issuer is a URL coffre may fetch, unless the binding carries
79
+ * its keys (`uploaded`): then coffre fetches nothing, and it is any string,
80
+ * matched exactly, such as a private Kubernetes cluster's.
81
+ */
76
82
  export type BindingPolicy = {
77
83
  profile: WorkloadProfile;
78
84
  issuer: string;
@@ -84,9 +90,13 @@ export declare class BindingInvalid extends Error {
84
90
  }
85
91
  /** 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. */
86
92
  export declare function defaultIssuer(profile: WorkloadProfile): string | null;
93
+ /** The longest issuer a binding with uploaded keys names, in bytes. */
94
+ export declare const MAX_ISSUER_BYTES = 400;
87
95
  /**
88
96
  * Check a binding against its profile, and return it as stored: the
89
- * issuer as given, the claims sorted by name. Throws `BindingInvalid`.
97
+ * issuer as given, the claims sorted by name. With `uploaded`, the binding
98
+ * carries its issuer's keys, and its issuer need not be a URL coffre can
99
+ * fetch. Throws `BindingInvalid`.
90
100
  */
91
101
  export declare function checkBinding(input: {
92
102
  profile: string;
@@ -94,13 +104,29 @@ export declare function checkBinding(input: {
94
104
  claims: Record<string, unknown>;
95
105
  }, options?: {
96
106
  allowLoopback?: boolean;
107
+ uploaded?: boolean;
97
108
  }): BindingPolicy;
98
109
  /** A claim's values, one or several. */
99
110
  export declare function claimValues(value: ClaimValue | undefined): string[];
111
+ /**
112
+ * A claim of a token, by the name a binding gives it: a top-level claim, or
113
+ * one nested in objects, its path joined by dots. A key may hold dots of
114
+ * its own, as Kubernetes' `kubernetes.io` does, so a name is read every way
115
+ * its dots can split it, and names a claim only when exactly one of those
116
+ * readings reaches a value; none, or several, and it names nothing. In
117
+ *
118
+ * { "sub": "system:serviceaccount:ci:deploy",
119
+ * "kubernetes.io": { "namespace": "ci", "serviceaccount": { "name": "deploy", "uid": "4f1c…" } } }
120
+ *
121
+ * `kubernetes.io.serviceaccount.uid` is `4f1c…`, `kubernetes.io` an object,
122
+ * and `kubernetes.io.pod` nothing.
123
+ */
124
+ export declare function claimAt(claims: Record<string, unknown>, name: string): unknown;
100
125
  /**
101
126
  * The claims of a verified token that differ from what a binding expects,
102
127
  * by name: none when it matches. A claim matches when the token carries its
103
- * value, or one of its values. On GitHub's profiles, two readings:
128
+ * value, or one of its values, where `claimAt` reads it. On GitHub's
129
+ * profiles, two readings:
104
130
  *
105
131
  * - A `pull_request` run, at `refs/pull/<n>/merge`, counts as a run at the
106
132
  * branch it merges into: its `ref`, and the ref its `workflow_ref` ends
package/dist/workloads.js CHANGED
@@ -4,10 +4,11 @@
4
4
  * their platform signs for them (docs/design/oidc.md).
5
5
  *
6
6
  * A binding names a profile, an issuer, and claims a token must carry, each
7
- * a top-level string matched exactly. The event that started the run may
8
- * be any of several: a list. The profile sets the minimum: the server
9
- * refuses a binding that lacks a claim its profile requires, from the UI,
10
- * the CLI or the API alike. Say a service is bound to `ci.yml` of
7
+ * a string matched exactly, at the top of the token or nested in it
8
+ * (`claimAt`). The event that started the run may be any of several: a
9
+ * list. The profile sets the minimum: the server refuses a binding that
10
+ * lacks a claim its profile requires, from the UI, the CLI or the API
11
+ * alike. Say a service is bound to `ci.yml` of
11
12
  * `acme/api`, pushed or dispatched on `main`, or run for a pull request
12
13
  * into `main`:
13
14
  *
@@ -170,16 +171,21 @@ function defaultIssuer(profile) {
170
171
  if (profile === "gitlab") return GITLAB_ISSUER;
171
172
  return profile === "custom" ? null : GITHUB_ISSUER;
172
173
  }
174
+ /** The longest issuer a binding with uploaded keys names, in bytes. */
175
+ const MAX_ISSUER_BYTES = 400;
173
176
  /**
174
177
  * Check a binding against its profile, and return it as stored: the
175
- * issuer as given, the claims sorted by name. Throws `BindingInvalid`.
178
+ * issuer as given, the claims sorted by name. With `uploaded`, the binding
179
+ * carries its issuer's keys, and its issuer need not be a URL coffre can
180
+ * fetch. Throws `BindingInvalid`.
176
181
  */
177
182
  function checkBinding(input, options = {}) {
178
183
  const profile = input.profile;
179
184
  if (!WORKLOAD_PROFILES.includes(profile)) throw new BindingInvalid(`unknown profile "${input.profile}": one of ${WORKLOAD_PROFILES.join(", ")}`);
180
185
  const issuer = input.issuer ?? defaultIssuer(profile);
181
186
  if (issuer === null) throw new BindingInvalid("a custom binding names its issuer");
182
- checkFetchUrl(issuer, "the issuer", {
187
+ if (options.uploaded === true) checkIssuerName(issuer);
188
+ else checkFetchUrl(issuer, "the issuer", {
183
189
  allowLoopback: options.allowLoopback,
184
190
  path: true,
185
191
  query: false
@@ -215,6 +221,17 @@ function checkBinding(input, options = {}) {
215
221
  claims
216
222
  };
217
223
  }
224
+ /**
225
+ * An issuer whose keys a binding carries: any string a token's `iss` can
226
+ * be, matched exactly, so none with a control character or a space at
227
+ * either end. Never github.com's or gitlab.com's, whose keys change when
228
+ * they rotate them: coffre fetches those.
229
+ */
230
+ function checkIssuerName(issuer) {
231
+ if (issuer === "" || /^\s|\s$/.test(issuer) || /\p{Cc}/u.test(issuer)) throw new BindingInvalid("the issuer is its tokens' iss, exactly: no control character, no space at either end");
232
+ if (new TextEncoder().encode(issuer).length > 400) throw new BindingInvalid(`the issuer is longer than 400 bytes`);
233
+ if (issuer === "https://token.actions.githubusercontent.com" || issuer === "https://gitlab.com") throw new BindingInvalid(`${issuer} publishes its keys and rotates them: coffre fetches them, so upload none`);
234
+ }
218
235
  /** A claim's values, one or several. */
219
236
  function claimValues(value) {
220
237
  return value === void 0 ? [] : typeof value === "string" ? [value] : value;
@@ -266,9 +283,43 @@ function checkGitLab(claims) {
266
283
  if (claims.ref.startsWith("refs/")) throw new BindingInvalid("GitLab's ref is the branch or tag name, without refs/");
267
284
  }
268
285
  /**
286
+ * A claim of a token, by the name a binding gives it: a top-level claim, or
287
+ * one nested in objects, its path joined by dots. A key may hold dots of
288
+ * its own, as Kubernetes' `kubernetes.io` does, so a name is read every way
289
+ * its dots can split it, and names a claim only when exactly one of those
290
+ * readings reaches a value; none, or several, and it names nothing. In
291
+ *
292
+ * { "sub": "system:serviceaccount:ci:deploy",
293
+ * "kubernetes.io": { "namespace": "ci", "serviceaccount": { "name": "deploy", "uid": "4f1c…" } } }
294
+ *
295
+ * `kubernetes.io.serviceaccount.uid` is `4f1c…`, `kubernetes.io` an object,
296
+ * and `kubernetes.io.pod` nothing.
297
+ */
298
+ function claimAt(claims, name) {
299
+ const parts = name.split(".");
300
+ let found;
301
+ let readings = 0;
302
+ const walk = (at, from) => {
303
+ if (readings > 1) return;
304
+ if (from === parts.length) {
305
+ found = at;
306
+ readings++;
307
+ return;
308
+ }
309
+ if (typeof at !== "object" || at === null || Array.isArray(at)) return;
310
+ for (let to = from + 1; to <= parts.length; to++) {
311
+ const key = parts.slice(from, to).join(".");
312
+ if (Object.hasOwn(at, key)) walk(at[key], to);
313
+ }
314
+ };
315
+ walk(claims, 0);
316
+ return readings === 1 ? found : void 0;
317
+ }
318
+ /**
269
319
  * The claims of a verified token that differ from what a binding expects,
270
320
  * by name: none when it matches. A claim matches when the token carries its
271
- * value, or one of its values. On GitHub's profiles, two readings:
321
+ * value, or one of its values, where `claimAt` reads it. On GitHub's
322
+ * profiles, two readings:
272
323
  *
273
324
  * - A `pull_request` run, at `refs/pull/<n>/merge`, counts as a run at the
274
325
  * branch it merges into: its `ref`, and the ref its `workflow_ref` ends
@@ -281,7 +332,7 @@ function differingClaims(profile, expected, token) {
281
332
  const github = profile.startsWith("github");
282
333
  const claims = github ? atBaseBranch(token) : token;
283
334
  return Object.entries(expected).filter(([name, value]) => {
284
- const actual = claims[name];
335
+ const actual = claimAt(claims, name);
285
336
  if (typeof actual !== "string") return true;
286
337
  if (github && name === "environment") return actual.toLowerCase() !== value.toLowerCase();
287
338
  return !claimValues(value).includes(actual);
@@ -408,4 +459,4 @@ function gitlabProject(input) {
408
459
  };
409
460
  }
410
461
  //#endregion
411
- export { BindingInvalid, EVENT_EXPOSURE, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, REQUIRED_CLAIMS, WORKLOAD_PROFILES, canonicalClaims, checkBinding, checkFetchUrl, claimValues, defaultIssuer, differingClaims, fullRef, githubEventsAt, githubReusable, githubWorkflow, gitlabProject, workflowRef };
462
+ export { BindingInvalid, EVENT_EXPOSURE, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, MAX_ISSUER_BYTES, REQUIRED_CLAIMS, WORKLOAD_PROFILES, canonicalClaims, checkBinding, checkFetchUrl, claimAt, claimValues, defaultIssuer, differingClaims, fullRef, githubEventsAt, githubReusable, githubWorkflow, gitlabProject, workflowRef };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/core",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "What coffre's packages share: roles, envelopes, identity, the audit chain and the vault's contract.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,6 +52,11 @@
52
52
  "types": "./dist/mcp.d.ts",
53
53
  "default": "./dist/mcp.js"
54
54
  },
55
+ "./recipe": {
56
+ "coffre:source": "./src/recipe.ts",
57
+ "types": "./dist/recipe.d.ts",
58
+ "default": "./dist/recipe.js"
59
+ },
55
60
  "./schemas": {
56
61
  "coffre:source": "./src/schemas.ts",
57
62
  "types": "./dist/schemas.d.ts",
@@ -84,7 +89,7 @@
84
89
  "@types/node": "26.1.1",
85
90
  "tsdown": "0.23.0",
86
91
  "typescript": "5.9.3",
87
- "@coffre/conformance": "0.6.2"
92
+ "@coffre/conformance": "0.8.0"
88
93
  },
89
94
  "scripts": {
90
95
  "build": "tsdown",
package/src/access.ts CHANGED
@@ -282,12 +282,37 @@ export function grantKind(grant: GrantPlace): 'project' | 'environment' | null {
282
282
  * account developer on market/dev, then viewer on market/prod too
283
283
  * token developer on market/dev, issued before market/prod
284
284
  * request reads and writes market/dev, never market/prod
285
+ *
286
+ * A person's personal token carries one too, each grant one they held when
287
+ * they made it (`delegable` roles only), and is weighed the same way
288
+ * against what they hold now, by their instance role or their grants.
285
289
  */
286
290
  export type CredentialScope = readonly (GrantPlace & { role: Role })[];
287
291
 
288
292
  /** The most places a scope asked for names: far more than any credential needs, and a bound on what a request makes the app check. */
289
293
  export const MAX_SCOPE_PLACES = 1000;
290
294
 
295
+ /**
296
+ * Whether a personal token's scope may name `role`: one that manages no
297
+ * access. A token acting as its person reaches their secrets and places,
298
+ * never who holds them, so it cannot hand anyone, itself included, more
299
+ * than it was given. A project's Owner who wants a token for all of it
300
+ * names `maintainer`.
301
+ */
302
+ export function delegable(role: Role): boolean {
303
+ return !roleGrants(role, 'grant.manage');
304
+ }
305
+
306
+ /** The roles a personal token's scope may name, in catalogue order. */
307
+ export const DELEGABLE_ROLES: readonly Role[] = ROLE_NAMES.filter(delegable);
308
+
309
+ /**
310
+ * The longest a personal token lasts: a week. It is for an afternoon's
311
+ * script or agent, and whoever needs it longer makes another, as a person,
312
+ * rather than keep one around.
313
+ */
314
+ export const MAX_PERSONAL_TOKEN_MINUTES = 7 * 24 * 60;
315
+
291
316
  /**
292
317
  * A credential's scope, by path as the API names it, in words, each role
293
318
  * with its places: `developer on market/dev, api/dev; viewer on market/prod`.
@@ -326,7 +351,13 @@ export type Holdings = {
326
351
  scope: Scope;
327
352
  /** Live grants only. */
328
353
  grants: readonly (GrantPlace & { role: Role })[];
329
- /** The scope of the credential a request came in on, past which nothing reaches; absent or null for a session's. */
354
+ /**
355
+ * The scope of the credential a request came in on, past which nothing
356
+ * reaches; absent or null for a session's. A scope names places, so a
357
+ * request made with one holds nothing of the instance's own: it does not
358
+ * run the instance, read its whole log or make projects, whatever its
359
+ * holder's instance role.
360
+ */
330
361
  credentialScope?: CredentialScope | null;
331
362
  };
332
363
 
@@ -365,6 +396,17 @@ export function allows(holder: Holdings, permission: Permission, place: Place):
365
396
  return holdsAt(holder.grants, permission, place);
366
397
  }
367
398
 
399
+ /**
400
+ * Whether the credential a request came in on, a scoped token or binding,
401
+ * leaves `permission` at `place` to its holder: always, for a session. A
402
+ * personal config's owner holds its every permission by reading its parent
403
+ * (docs/design/forking.md), and a token of theirs is still held to the
404
+ * role its scope names there: a viewer's token reads it, and writes nothing.
405
+ */
406
+ export function withinCredential(holder: Holdings, permission: Permission, place: Place): boolean {
407
+ return holder.credentialScope == null || holdsAt(holder.credentialScope, permission, place);
408
+ }
409
+
368
410
  /** Whether a grant among `grants` covers `place` with a role that holds `permission`. */
369
411
  function holdsAt(grants: readonly (GrantPlace & { role: Role })[], permission: Permission, place: Place): boolean {
370
412
  return grants.some((grant) => covers(grant, place) && roleGrants(grant.role, permission));
@@ -388,7 +430,7 @@ export function mayManageAccess(actor: Holdings, place: Place): boolean {
388
430
  * does none of these.
389
431
  */
390
432
  export function runsInstance(holder: Holdings): boolean {
391
- return administers(holder.role) && unscoped(holder.scope);
433
+ return holder.credentialScope == null && administers(holder.role) && unscoped(holder.scope);
392
434
  }
393
435
 
394
436
  /**
@@ -408,11 +450,13 @@ export type ServiceSetting = Scope;
408
450
  * everything `role` does. A Developer on `market/dev` gives viewer or
409
451
  * developer there, never maintainer; a grant on `market` takes holding it
410
452
  * on all of `market`. `setting` is null for a service account, which sets
411
- * up none.
453
+ * up none; a request on a credential with a scope sets none up either,
454
+ * since what it set up would outlive it.
412
455
  */
413
456
  export function givesService(holder: Holdings, setting: ServiceSetting | null, role: Role, place: Place): boolean {
414
457
  if (mayManageAccess(holder, place)) return true;
415
- return setting !== null && inScope(setting, place) && ROLES[role].permissions.every((permission) => allows(holder, permission, place));
458
+ return setting !== null && holder.credentialScope == null && inScope(setting, place)
459
+ && ROLES[role].permissions.every((permission) => allows(holder, permission, place));
416
460
  }
417
461
 
418
462
  /** Whether `holder` gives a service account anything anywhere among `places`: whether they set any up. */
@@ -475,7 +519,7 @@ export function issuesCredential(
475
519
  * Anyone else reads the places they hold `audit.read` in.
476
520
  */
477
521
  export function readsWholeLog(holder: Holdings): boolean {
478
- return instanceRoleGrants(holder.role, 'audit.read') && unscoped(holder.scope);
522
+ return holder.credentialScope == null && instanceRoleGrants(holder.role, 'audit.read') && unscoped(holder.scope);
479
523
  }
480
524
 
481
525
  /**
@@ -486,7 +530,7 @@ export function readsWholeLog(holder: Holdings): boolean {
486
530
  */
487
531
  export function makesProjects(holder: Holdings): boolean {
488
532
  const { projects, environments } = holder.scope;
489
- return administers(holder.role) && environments === 'all' && (projects === 'all' || 'except' in projects);
533
+ return holder.credentialScope == null && administers(holder.role) && environments === 'all' && (projects === 'all' || 'except' in projects);
490
534
  }
491
535
 
492
536
  export {
package/src/context.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  /**
2
4
  * The binding context for a secret's ciphertext.
3
5
  *
@@ -9,11 +11,17 @@
9
11
  * These are immutable UUIDs, never names. Binding to `project/environment/key`
10
12
  * names would mean that renaming an environment or a secret key makes every
11
13
  * existing ciphertext permanently undecryptable.
14
+ *
15
+ * A personal config's values are bound to their owner too, `owner`, which
16
+ * only the vault's wrap of the data key carries (docs/design/forking.md,
17
+ * section 4): a forged owner column fails to open, rather than granting.
12
18
  */
13
19
  export type SecretContext = {
14
20
  projectId: string;
15
21
  environmentId: string;
16
22
  secretId: string;
23
+ /** A personal config's owner, as `ownerKey` hashes them. */
24
+ owner?: string;
17
25
  };
18
26
 
19
27
  /**
@@ -31,11 +39,25 @@ function assertUuid(value: string, field: string): void {
31
39
  }
32
40
  }
33
41
 
34
- /** Throw unless every field is a lowercase UUID. */
42
+ /** Throw unless every field is a lowercase UUID, and an owner a lowercase SHA-256. */
35
43
  export function checkContext(ctx: SecretContext): void {
36
44
  assertUuid(ctx.projectId, 'projectId');
37
45
  assertUuid(ctx.environmentId, 'environmentId');
38
46
  assertUuid(ctx.secretId, 'secretId');
47
+ if (ctx.owner !== undefined && (typeof ctx.owner !== 'string' || !OWNER_RE.test(ctx.owner))) {
48
+ throw new Error(`context.owner must be a lowercase SHA-256, got: ${String(ctx.owner)}`);
49
+ }
50
+ }
51
+
52
+ const OWNER_RE = /^[0-9a-f]{64}$/;
53
+
54
+ /**
55
+ * A personal config's owner as its values' context binds them: the SHA-256
56
+ * of the member, `user:ada@acme.example`. Hashed, so it joins the AAD as
57
+ * unambiguously as a UUID: an email can hold the separator.
58
+ */
59
+ export function ownerKey(member: string): string {
60
+ return createHash('sha256').update(member, 'utf8').digest('hex');
39
61
  }
40
62
 
41
63
  /**
@@ -48,7 +70,7 @@ export function checkContext(ctx: SecretContext): void {
48
70
  export function encodeAad(ctx: SecretContext): Buffer {
49
71
  checkContext(ctx);
50
72
  return Buffer.from(
51
- `${AAD_VERSION}|${ctx.projectId}|${ctx.environmentId}|${ctx.secretId}`,
73
+ `${AAD_VERSION}|${ctx.projectId}|${ctx.environmentId}|${ctx.secretId}${ctx.owner === undefined ? '' : `|owner:${ctx.owner}`}`,
52
74
  'utf8',
53
75
  );
54
76
  }
@@ -1,8 +1,11 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
 
3
3
  /**
4
- * Bearer credentials coffre issues itself: browser sessions, CLI sessions and
5
- * service tokens.
4
+ * Bearer credentials coffre issues itself: browser sessions, CLI sessions,
5
+ * service tokens, personal tokens, which act as the person who made
6
+ * them within the scope they named, and handles, which reach nothing but
7
+ * the credential proxy (`@coffre/server/plugins/credential-proxy`): the
8
+ * stand-in a person's app holds in place of a secret's value.
6
9
  *
7
10
  * A token is a kind prefix and 32 random bytes, e.g.
8
11
  * `coffre_cli_Qm9Y...`. The prefix tells a person (or a secret scanner) what a
@@ -11,15 +14,17 @@ import { createHash, randomBytes } from 'node:crypto';
11
14
  * right tool here, unlike for passwords: 256 bits of randomness leave nothing
12
15
  * to brute-force.
13
16
  */
14
- export type CredentialKind = 'browser' | 'cli' | 'service';
17
+ export type CredentialKind = 'browser' | 'cli' | 'service' | 'personal' | 'handle';
15
18
 
16
19
  const PREFIX: Record<CredentialKind, string> = {
17
20
  browser: 'coffre_web_',
18
21
  cli: 'coffre_cli_',
19
22
  service: 'coffre_svc_',
23
+ personal: 'coffre_pat_',
24
+ handle: 'coffre_use_',
20
25
  };
21
26
 
22
- const TOKEN = /^coffre_(web|cli|svc)_[A-Za-z0-9_-]{43}$/;
27
+ const TOKEN = /^coffre_(web|cli|svc|pat|use)_[A-Za-z0-9_-]{43}$/;
23
28
 
24
29
  export function generateToken(kind: CredentialKind): string {
25
30
  return PREFIX[kind] + randomBytes(32).toString('base64url');
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
- import { createLocalJWKSet, errors, jwtVerify, type JSONWebKeySet, type JWTPayload } from 'jose';
3
+ import { createLocalJWKSet, errors, importJWK, jwtVerify, type JSONWebKeySet, type JWK, type JWTPayload } from 'jose';
4
4
 
5
5
  export type { JSONWebKeySet };
6
6
 
@@ -30,6 +30,8 @@ export type WorkloadRefusal =
30
30
  | 'expired'
31
31
  /** Its `iat` is more than an hour ago, or not yet. */
32
32
  | 'too_old'
33
+ /** It lives longer than an hour, `exp` − `iat`, where its binding carries its keys. */
34
+ | 'too_long'
33
35
  /** Its `nbf` is still to come. */
34
36
  | 'not_yet_valid'
35
37
  /** Its audience is not this instance alone. */
@@ -96,28 +98,34 @@ export function decodeWorkloadToken(token: string): DecodedToken {
96
98
  * algorithms fixed and nothing taken from the header but `alg` and `kid`;
97
99
  * the issuer exactly; `exp`, `nbf` and `iat` within the tolerance, `iat` at
98
100
  * most an hour old; and the audience, this instance's public URL alone.
99
- * Returns the verified claims.
101
+ * With `maxLifetime`, in seconds, a token whose `exp` is further than that
102
+ * from its `iat` is refused too. Returns the verified claims.
100
103
  */
101
104
  export async function verifyWorkloadToken(
102
105
  token: string,
103
106
  keys: JSONWebKeySet,
104
- expected: { issuer: string; audience: string; now: Date },
107
+ expected: { issuer: string; audience: string; now: Date; maxLifetime?: number },
105
108
  ): Promise<JWTPayload & DecodedToken['claims']> {
109
+ const options = {
110
+ algorithms: [...WORKLOAD_ALGORITHMS],
111
+ issuer: expected.issuer,
112
+ // For exp and nbf; iat's bounds are issuedRefusal's, since jose would stretch the hour by the tolerance too.
113
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
114
+ requiredClaims: ['iss', 'sub', 'exp', 'iat'],
115
+ currentDate: expected.now,
116
+ };
106
117
  let payload: JWTPayload;
107
118
  try {
108
- ({ payload } = await jwtVerify(token, createLocalJWKSet(keys), {
109
- algorithms: [...WORKLOAD_ALGORITHMS],
110
- issuer: expected.issuer,
111
- // For exp and nbf; iat's bounds are issuedRefusal's, since jose would stretch the hour by the tolerance too.
112
- clockTolerance: CLOCK_TOLERANCE_SECONDS,
113
- requiredClaims: ['iss', 'sub', 'exp', 'iat'],
114
- currentDate: expected.now,
115
- }));
119
+ ({ payload } = await jwtVerify(token, createLocalJWKSet(keys), options));
116
120
  } catch (error) {
117
- throw refusalOf(error);
121
+ if (!(error instanceof errors.JWKSMultipleMatchingKeys)) throw refusalOf(error);
122
+ payload = await anyKey(token, error, options);
118
123
  }
119
124
  const issued = issuedRefusal(payload.iat!, expected.now.getTime() / 1000);
120
125
  if (issued !== null) throw issued;
126
+ if (expected.maxLifetime !== undefined && payload.exp! - payload.iat! > expected.maxLifetime) {
127
+ throw new WorkloadTokenRefused('too_long', `the token lives longer than ${expected.maxLifetime / 60} minutes, from its iat to its exp: ask for a shorter one`);
128
+ }
121
129
  const { aud } = payload;
122
130
  if (!(aud === expected.audience || (Array.isArray(aud) && aud.length === 1 && aud[0] === expected.audience))) {
123
131
  throw new WorkloadTokenRefused('audience', `the token is for ${JSON.stringify(aud ?? null)}, not ${expected.audience} alone`);
@@ -139,6 +147,22 @@ export function issuedRefusal(iat: number, now: number): WorkloadTokenRefused |
139
147
  return null;
140
148
  }
141
149
 
150
+ /**
151
+ * A token that names no key, or one several keys share, verified by
152
+ * whichever of them signed it: a set may hold several, as while its issuer
153
+ * rotates. At most `MAX_KEYS` tries, each a signature check alone.
154
+ */
155
+ async function anyKey(token: string, matching: errors.JWKSMultipleMatchingKeys, options: Parameters<typeof jwtVerify>[2]): Promise<JWTPayload> {
156
+ for await (const key of matching) {
157
+ try {
158
+ return (await jwtVerify(token, key, options)).payload;
159
+ } catch (error) {
160
+ if (!(error instanceof errors.JWSSignatureVerificationFailed)) throw refusalOf(error);
161
+ }
162
+ }
163
+ throw new WorkloadTokenRefused('signature', "the token's signature does not verify");
164
+ }
165
+
142
166
  function refusalOf(error: unknown): WorkloadTokenRefused {
143
167
  if (error instanceof errors.JWKSNoMatchingKey) return new WorkloadTokenRefused('unknown_key', "the token's key is not among the issuer's");
144
168
  if (error instanceof errors.JWTExpired) {
@@ -161,3 +185,124 @@ function refusalOf(error: unknown): WorkloadTokenRefused {
161
185
  if (error instanceof errors.JOSEError) return new WorkloadTokenRefused('signature', `the token does not verify: ${error.code}`);
162
186
  return new WorkloadTokenRefused('signature', 'the token does not verify');
163
187
  }
188
+
189
+ /** At most this many keys in a set, fetched or uploaded. */
190
+ export const MAX_KEYS = 32;
191
+
192
+ /** One key of an uploaded set, as an owner sees it: its `kid`, its algorithm, and its RFC 7638 thumbprint. */
193
+ export type KeySummary = { kid: string | null; alg: (typeof WORKLOAD_ALGORITHMS)[number]; thumbprint: string };
194
+
195
+ /**
196
+ * A key set uploaded with a binding, as the binding keeps it: its public
197
+ * keys alone, as canonical JSON, and that JSON's SHA-256, in hex, which
198
+ * stands in the binding where a URL would and so is under its MAC.
199
+ */
200
+ export type UploadedKeys = { jwks: string; digest: string; keys: KeySummary[] };
201
+
202
+ /** A key set that cannot be uploaded, said so an owner can fix it. */
203
+ export class KeysInvalid extends Error {
204
+ constructor(message: string) {
205
+ super(message);
206
+ this.name = 'KeysInvalid';
207
+ }
208
+ }
209
+
210
+ /** Members that only a private or a symmetric key has. */
211
+ const PRIVATE_MEMBERS = ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'];
212
+
213
+ /**
214
+ * An issuer's public keys, as uploaded for a binding: a JWKS, `{ "keys": [
215
+ * … ] }`, of RSA keys of 2048 bits or more, for RS256, and P-256 keys, for
216
+ * ES256, each kept with only the members that verify (`kty`, `kid`, `use`,
217
+ * `alg` and the key), in the order of their thumbprints, so that one set
218
+ * however listed has one digest. An RSA key's parameters are checked
219
+ * before jose sees them, since it takes some that verify anything: an
220
+ * exponent of 1 makes every signature its message. Keys for encryption are
221
+ * left out, as from a fetched set; a private or a symmetric key refuses the
222
+ * whole set, since coffre keeps no secret of an issuer's. Throws
223
+ * `KeysInvalid`.
224
+ */
225
+ export async function uploadedKeys(value: unknown): Promise<UploadedKeys> {
226
+ const listed = (value as { keys?: unknown } | null)?.keys;
227
+ if (typeof value !== 'object' || value === null || !Array.isArray(listed)) throw new KeysInvalid('the keys are a JWKS: { "keys": [ … ] }');
228
+ if (listed.length > MAX_KEYS) throw new KeysInvalid(`a key set holds at most ${MAX_KEYS} keys`);
229
+ const keys: JWK[] = [];
230
+ for (const [i, given] of listed.entries()) {
231
+ if (typeof given !== 'object' || given === null || Array.isArray(given)) throw new KeysInvalid(`key ${i + 1} is not a JWK`);
232
+ const jwk = given as Record<string, unknown>;
233
+ const named = typeof jwk.kid === 'string' ? `key ${jwk.kid}` : `key ${i + 1}`;
234
+ if (PRIVATE_MEMBERS.some((member) => member in jwk)) {
235
+ throw new KeysInvalid(`${named} is a private or a secret key: upload the issuer's public keys only`);
236
+ }
237
+ if (jwk.use === 'enc') continue;
238
+ if (jwk.use !== undefined && jwk.use !== 'sig') throw new KeysInvalid(`${named} has use ${JSON.stringify(jwk.use)}: sig, or none`);
239
+ if (jwk.kid !== undefined && (typeof jwk.kid !== 'string' || jwk.kid === '' || jwk.kid.length > 256)) throw new KeysInvalid(`key ${i + 1}'s kid is not a string`);
240
+ if (jwk.kid !== undefined && keys.some((key) => key.kid === jwk.kid)) throw new KeysInvalid(`two keys are ${named}`);
241
+ let alg: KeySummary['alg'];
242
+ let key: JWK;
243
+ if (jwk.kty === 'RSA' && (jwk.alg === undefined || jwk.alg === 'RS256')) {
244
+ alg = 'RS256';
245
+ const [n, e] = [integer(jwk.n), integer(jwk.e)];
246
+ if (n === null || e === null) throw new KeysInvalid(`${named} is an RSA key without n and e, each a base64url integer in its fewest bytes`);
247
+ // The modulus's bits counted from its highest set bit, as jose counts them when it verifies.
248
+ if (n.toString(2).length < 2048) throw new KeysInvalid(`${named} is an RSA key under 2048 bits`);
249
+ if (n % 2n === 0n) throw new KeysInvalid(`${named} is not a valid RSA key`);
250
+ // As NIST SP 800-56B has it: odd, above 2^16, below 2^256.
251
+ if (e % 2n === 0n || e <= 65536n || e >= 2n ** 256n) throw new KeysInvalid(`${named}'s exponent is not one an RSA key signs with: odd, from 65537`);
252
+ key = { kty: 'RSA', ...kidAndUse(jwk), alg, n: jwk.n as string, e: jwk.e as string };
253
+ } else if (jwk.kty === 'EC' && jwk.crv === 'P-256' && (jwk.alg === undefined || jwk.alg === 'ES256')) {
254
+ alg = 'ES256';
255
+ if (!coordinate(jwk.x) || !coordinate(jwk.y)) throw new KeysInvalid(`${named} is an EC key without x and y, each 32 bytes in base64url`);
256
+ key = { kty: 'EC', ...kidAndUse(jwk), alg, crv: 'P-256', x: jwk.x, y: jwk.y };
257
+ } else {
258
+ throw new KeysInvalid(`${named} is not an RSA key for RS256 or a P-256 key for ES256, the two coffre accepts`);
259
+ }
260
+ try {
261
+ await importJWK(key, alg);
262
+ } catch {
263
+ throw new KeysInvalid(`${named} is not a valid ${key.kty} key`);
264
+ }
265
+ keys.push(key);
266
+ }
267
+ if (keys.length === 0) throw new KeysInvalid('the key set holds no key that signs');
268
+ // By thumbprint, then whole: two kids may name the same key.
269
+ const sorted = keys.map((key) => [`${thumbprint(key)} ${JSON.stringify(key)}`, key] as const).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
270
+ const jwks = JSON.stringify({ keys: sorted.map(([, key]) => key) });
271
+ return { jwks, digest: createHash('sha256').update(jwks, 'utf8').digest('hex'), keys: keySummaries(jwks) };
272
+ }
273
+
274
+ /** The keys of a set `uploadedKeys` kept, as an owner sees them. */
275
+ export function keySummaries(jwks: string): KeySummary[] {
276
+ return (JSON.parse(jwks) as JSONWebKeySet).keys.map((key) => ({
277
+ kid: key.kid ?? null,
278
+ alg: key.kty === 'RSA' ? 'RS256' : 'ES256',
279
+ thumbprint: thumbprint(key),
280
+ }));
281
+ }
282
+
283
+ /** A key's RFC 7638 thumbprint: the SHA-256 of its required members alone, in lexicographic order. */
284
+ function thumbprint(key: JWK): string {
285
+ const required = key.kty === 'RSA' ? { e: key.e, kty: key.kty, n: key.n } : { crv: key.crv, kty: key.kty, x: key.x, y: key.y };
286
+ return createHash('sha256').update(JSON.stringify(required), 'utf8').digest('base64url');
287
+ }
288
+
289
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
290
+
291
+ /** A P-256 coordinate as JWK writes one: 32 bytes, in base64url exactly, so that one key has one spelling. */
292
+ function coordinate(value: unknown): value is string {
293
+ if (typeof value !== 'string' || !BASE64URL.test(value)) return false;
294
+ const bytes = Buffer.from(value, 'base64url');
295
+ return bytes.length === 32 && bytes.toString('base64url') === value;
296
+ }
297
+
298
+ /** An unsigned integer as JWK writes one, base64url in its fewest bytes; null for anything else. */
299
+ function integer(value: unknown): bigint | null {
300
+ if (typeof value !== 'string' || !BASE64URL.test(value)) return null;
301
+ const bytes = Buffer.from(value, 'base64url');
302
+ if (bytes.length === 0 || bytes[0] === 0 || bytes.toString('base64url') !== value) return null;
303
+ return BigInt(`0x${bytes.toString('hex')}`);
304
+ }
305
+
306
+ function kidAndUse(jwk: Record<string, unknown>): Pick<JWK, 'kid' | 'use'> {
307
+ return { ...(jwk.kid === undefined ? {} : { kid: jwk.kid as string }), ...(jwk.use === undefined ? {} : { use: 'sig' }) };
308
+ }