@coffre/core 0.6.2 → 0.7.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/dist/{access-DvwZzqRR.js → access-B1wpMzEB.js} +35 -6
- package/dist/{access-BpcBLDW9.d.ts → access-CONhKG2P.d.ts} +38 -3
- package/dist/access.d.ts +2 -2
- package/dist/access.js +2 -2
- package/dist/envelope.d.ts +2 -2
- package/dist/envelope.js +2 -2
- package/dist/identity.d.ts +48 -6
- package/dist/identity.js +169 -19
- package/dist/kek.d.ts +1 -1
- package/dist/kek.js +3 -2
- package/dist/recipe.d.ts +126 -0
- package/dist/recipe.js +281 -0
- package/dist/{registry-C0NGpxcc.d.ts → registry-Bd7hPoY4.d.ts} +14 -2
- package/dist/schemas.d.ts +10 -0
- package/dist/schemas.js +14 -2
- package/dist/{types-Mi7-Tzj9.js → types-E1Sz50n6.js} +14 -3
- package/dist/vault.d.ts +11 -2
- package/dist/workloads.d.ts +33 -7
- package/dist/workloads.js +60 -9
- package/package.json +7 -2
- package/src/access.ts +50 -6
- package/src/context.ts +24 -2
- package/src/identity/tokens.ts +6 -4
- package/src/identity/workload-token.ts +157 -12
- package/src/identity/workloads.ts +74 -10
- package/src/kek/aws-kms.ts +7 -1
- package/src/recipe.ts +309 -0
- package/src/schemas.ts +15 -0
- package/src/vault.ts +10 -2
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.7.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.
|
|
92
|
+
"@coffre/conformance": "0.7.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
|
-
/**
|
|
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 &&
|
|
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
|
}
|
package/src/identity/tokens.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Bearer credentials coffre issues itself: browser sessions, CLI sessions
|
|
5
|
-
* service tokens
|
|
4
|
+
* Bearer credentials coffre issues itself: browser sessions, CLI sessions,
|
|
5
|
+
* service tokens, and personal tokens, which act as the person who made
|
|
6
|
+
* them within the scope they named.
|
|
6
7
|
*
|
|
7
8
|
* A token is a kind prefix and 32 random bytes, e.g.
|
|
8
9
|
* `coffre_cli_Qm9Y...`. The prefix tells a person (or a secret scanner) what a
|
|
@@ -11,15 +12,16 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
11
12
|
* right tool here, unlike for passwords: 256 bits of randomness leave nothing
|
|
12
13
|
* to brute-force.
|
|
13
14
|
*/
|
|
14
|
-
export type CredentialKind = 'browser' | 'cli' | 'service';
|
|
15
|
+
export type CredentialKind = 'browser' | 'cli' | 'service' | 'personal';
|
|
15
16
|
|
|
16
17
|
const PREFIX: Record<CredentialKind, string> = {
|
|
17
18
|
browser: 'coffre_web_',
|
|
18
19
|
cli: 'coffre_cli_',
|
|
19
20
|
service: 'coffre_svc_',
|
|
21
|
+
personal: 'coffre_pat_',
|
|
20
22
|
};
|
|
21
23
|
|
|
22
|
-
const TOKEN = /^coffre_(web|cli|svc)_[A-Za-z0-9_-]{43}$/;
|
|
24
|
+
const TOKEN = /^coffre_(web|cli|svc|pat)_[A-Za-z0-9_-]{43}$/;
|
|
23
25
|
|
|
24
26
|
export function generateToken(kind: CredentialKind): string {
|
|
25
27
|
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
|
-
*
|
|
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
|
+
}
|