@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.
- 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 +50 -6
- package/dist/identity.js +170 -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 +13 -3
- package/dist/schemas.js +14 -2
- package/dist/{types-Mi7-Tzj9.js → types-E1Sz50n6.js} +14 -3
- package/dist/vault.d.ts +31 -4
- package/dist/vault.js +10 -1
- 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 +9 -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 +31 -4
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* their platform signs for them (docs/design/oidc.md).
|
|
4
4
|
*
|
|
5
5
|
* A binding names a profile, an issuer, and claims a token must carry, each
|
|
6
|
-
* a
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* a string matched exactly, at the top of the token or nested in it
|
|
7
|
+
* (`claimAt`). The event that started the run may be any of several: a
|
|
8
|
+
* list. The profile sets the minimum: the server refuses a binding that
|
|
9
|
+
* lacks a claim its profile requires, from the UI, the CLI or the API
|
|
10
|
+
* alike. Say a service is bound to `ci.yml` of
|
|
10
11
|
* `acme/api`, pushed or dispatched on `main`, or run for a pull request
|
|
11
12
|
* into `main`:
|
|
12
13
|
*
|
|
@@ -124,7 +125,12 @@ const ENVIRONMENT = /^(?! )[^\p{Cc}]{1,255}(?<! )$/u;
|
|
|
124
125
|
export type ClaimValue = string | string[];
|
|
125
126
|
export type BindingClaims = Record<string, ClaimValue>;
|
|
126
127
|
|
|
127
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* What makes a binding, checked: its profile, its issuer, and its claims,
|
|
130
|
+
* sorted. The issuer is a URL coffre may fetch, unless the binding carries
|
|
131
|
+
* its keys (`uploaded`): then coffre fetches nothing, and it is any string,
|
|
132
|
+
* matched exactly, such as a private Kubernetes cluster's.
|
|
133
|
+
*/
|
|
128
134
|
export type BindingPolicy = { profile: WorkloadProfile; issuer: string; claims: BindingClaims };
|
|
129
135
|
|
|
130
136
|
/** A binding that breaks a rule, said so an owner can fix it. */
|
|
@@ -148,13 +154,18 @@ export function defaultIssuer(profile: WorkloadProfile): string | null {
|
|
|
148
154
|
return profile === 'custom' ? null : GITHUB_ISSUER;
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
/** The longest issuer a binding with uploaded keys names, in bytes. */
|
|
158
|
+
export const MAX_ISSUER_BYTES = 400;
|
|
159
|
+
|
|
151
160
|
/**
|
|
152
161
|
* Check a binding against its profile, and return it as stored: the
|
|
153
|
-
* issuer as given, the claims sorted by name.
|
|
162
|
+
* issuer as given, the claims sorted by name. With `uploaded`, the binding
|
|
163
|
+
* carries its issuer's keys, and its issuer need not be a URL coffre can
|
|
164
|
+
* fetch. Throws `BindingInvalid`.
|
|
154
165
|
*/
|
|
155
166
|
export function checkBinding(
|
|
156
167
|
input: { profile: string; issuer: string | null; claims: Record<string, unknown> },
|
|
157
|
-
options: { allowLoopback?: boolean } = {},
|
|
168
|
+
options: { allowLoopback?: boolean; uploaded?: boolean } = {},
|
|
158
169
|
): BindingPolicy {
|
|
159
170
|
const profile = input.profile as WorkloadProfile;
|
|
160
171
|
if (!WORKLOAD_PROFILES.includes(profile)) {
|
|
@@ -162,7 +173,8 @@ export function checkBinding(
|
|
|
162
173
|
}
|
|
163
174
|
const issuer = input.issuer ?? defaultIssuer(profile);
|
|
164
175
|
if (issuer === null) throw new BindingInvalid('a custom binding names its issuer');
|
|
165
|
-
|
|
176
|
+
if (options.uploaded === true) checkIssuerName(issuer);
|
|
177
|
+
else checkFetchUrl(issuer, 'the issuer', { allowLoopback: options.allowLoopback, path: true, query: false });
|
|
166
178
|
const forced = forcedProfiles(issuer);
|
|
167
179
|
if (forced !== null && !forced.includes(profile)) {
|
|
168
180
|
throw new BindingInvalid(`${issuer} signs only for the ${forced[0]} profile${forced.length > 1 ? 's' : ''}`);
|
|
@@ -204,6 +216,22 @@ export function checkBinding(
|
|
|
204
216
|
return { profile, issuer, claims };
|
|
205
217
|
}
|
|
206
218
|
|
|
219
|
+
/**
|
|
220
|
+
* An issuer whose keys a binding carries: any string a token's `iss` can
|
|
221
|
+
* be, matched exactly, so none with a control character or a space at
|
|
222
|
+
* either end. Never github.com's or gitlab.com's, whose keys change when
|
|
223
|
+
* they rotate them: coffre fetches those.
|
|
224
|
+
*/
|
|
225
|
+
function checkIssuerName(issuer: string): void {
|
|
226
|
+
if (issuer === '' || /^\s|\s$/.test(issuer) || /\p{Cc}/u.test(issuer)) {
|
|
227
|
+
throw new BindingInvalid('the issuer is its tokens\' iss, exactly: no control character, no space at either end');
|
|
228
|
+
}
|
|
229
|
+
if (new TextEncoder().encode(issuer).length > MAX_ISSUER_BYTES) throw new BindingInvalid(`the issuer is longer than ${MAX_ISSUER_BYTES} bytes`);
|
|
230
|
+
if (issuer === GITHUB_ISSUER || issuer === GITLAB_ISSUER) {
|
|
231
|
+
throw new BindingInvalid(`${issuer} publishes its keys and rotates them: coffre fetches them, so upload none`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
/** A claim's values, one or several. */
|
|
208
236
|
export function claimValues(value: ClaimValue | undefined): string[] {
|
|
209
237
|
return value === undefined ? [] : typeof value === 'string' ? [value] : value;
|
|
@@ -258,10 +286,46 @@ function checkGitLab(claims: BindingClaims): void {
|
|
|
258
286
|
if ((claims.ref as string).startsWith('refs/')) throw new BindingInvalid('GitLab\'s ref is the branch or tag name, without refs/');
|
|
259
287
|
}
|
|
260
288
|
|
|
289
|
+
/**
|
|
290
|
+
* A claim of a token, by the name a binding gives it: a top-level claim, or
|
|
291
|
+
* one nested in objects, its path joined by dots. A key may hold dots of
|
|
292
|
+
* its own, as Kubernetes' `kubernetes.io` does, so a name is read every way
|
|
293
|
+
* its dots can split it, and names a claim only when exactly one of those
|
|
294
|
+
* readings reaches a value; none, or several, and it names nothing. In
|
|
295
|
+
*
|
|
296
|
+
* { "sub": "system:serviceaccount:ci:deploy",
|
|
297
|
+
* "kubernetes.io": { "namespace": "ci", "serviceaccount": { "name": "deploy", "uid": "4f1c…" } } }
|
|
298
|
+
*
|
|
299
|
+
* `kubernetes.io.serviceaccount.uid` is `4f1c…`, `kubernetes.io` an object,
|
|
300
|
+
* and `kubernetes.io.pod` nothing.
|
|
301
|
+
*/
|
|
302
|
+
export function claimAt(claims: Record<string, unknown>, name: string): unknown {
|
|
303
|
+
const parts = name.split('.');
|
|
304
|
+
let found: unknown;
|
|
305
|
+
let readings = 0;
|
|
306
|
+
// Each step takes one key, of one or more parts, from the object it is in; stops once a second reading shows.
|
|
307
|
+
const walk = (at: unknown, from: number): void => {
|
|
308
|
+
if (readings > 1) return;
|
|
309
|
+
if (from === parts.length) {
|
|
310
|
+
found = at;
|
|
311
|
+
readings++;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (typeof at !== 'object' || at === null || Array.isArray(at)) return;
|
|
315
|
+
for (let to = from + 1; to <= parts.length; to++) {
|
|
316
|
+
const key = parts.slice(from, to).join('.');
|
|
317
|
+
if (Object.hasOwn(at, key)) walk((at as Record<string, unknown>)[key], to);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
walk(claims, 0);
|
|
321
|
+
return readings === 1 ? found : undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
261
324
|
/**
|
|
262
325
|
* The claims of a verified token that differ from what a binding expects,
|
|
263
326
|
* by name: none when it matches. A claim matches when the token carries its
|
|
264
|
-
* value, or one of its values. On GitHub's
|
|
327
|
+
* value, or one of its values, where `claimAt` reads it. On GitHub's
|
|
328
|
+
* profiles, two readings:
|
|
265
329
|
*
|
|
266
330
|
* - A `pull_request` run, at `refs/pull/<n>/merge`, counts as a run at the
|
|
267
331
|
* branch it merges into: its `ref`, and the ref its `workflow_ref` ends
|
|
@@ -275,7 +339,7 @@ export function differingClaims(profile: WorkloadProfile, expected: BindingClaim
|
|
|
275
339
|
const claims = github ? atBaseBranch(token) : token;
|
|
276
340
|
return Object.entries(expected)
|
|
277
341
|
.filter(([name, value]) => {
|
|
278
|
-
const actual = claims
|
|
342
|
+
const actual = claimAt(claims, name);
|
|
279
343
|
if (typeof actual !== 'string') return true;
|
|
280
344
|
if (github && name === 'environment') return actual.toLowerCase() !== (value as string).toLowerCase();
|
|
281
345
|
return !claimValues(value).includes(actual);
|
package/src/kek/aws-kms.ts
CHANGED
|
@@ -214,7 +214,13 @@ export class AwsKmsKekProvider implements KekProvider {
|
|
|
214
214
|
*/
|
|
215
215
|
function encryptionContext(ctx: SecretContext): Record<string, string> {
|
|
216
216
|
checkContext(ctx);
|
|
217
|
-
return {
|
|
217
|
+
return {
|
|
218
|
+
'coffre:project': ctx.projectId,
|
|
219
|
+
'coffre:environment': ctx.environmentId,
|
|
220
|
+
'coffre:secret': ctx.secretId,
|
|
221
|
+
// A personal config's owner, hashed: KMS refuses to decrypt under any other.
|
|
222
|
+
...(ctx.owner === undefined ? {} : { 'coffre:owner': ctx.owner }),
|
|
223
|
+
};
|
|
218
224
|
}
|
|
219
225
|
|
|
220
226
|
/**
|
package/src/recipe.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a secret's value is made, when a client makes it rather than a person
|
|
3
|
+
* typing it: random strings, AES keys and EC key pairs, from Web Crypto
|
|
4
|
+
* alone, the same in the browser and in the CLI.
|
|
5
|
+
*
|
|
6
|
+
* A recipe is short text, `bytes:32:hex`, which the CLI takes as
|
|
7
|
+
* `--generate` and the server stores beside the version it made, so that
|
|
8
|
+
* `coffre regenerate` and the page's Regenerate make the next one the same
|
|
9
|
+
* way. The server checks a recipe's shape and never makes a value: a value
|
|
10
|
+
* is made where it is sent from, and nothing here talks to the server.
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* bytes:<1-256>[:base62|hex|base64|base64url] random bytes, base62 unless named
|
|
14
|
+
* aes:<128|256> an AES-GCM key: the raw key, base64
|
|
15
|
+
* ecdsa:<p-256|p-384|p-521> a key pair: PKCS #8 private, SPKI public, base64
|
|
16
|
+
* ecdh:<p-256|p-384|p-521>
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type Encoding = 'base62' | 'hex' | 'base64' | 'base64url';
|
|
21
|
+
export type AesSize = 128 | 256;
|
|
22
|
+
export type Curve = 'P-256' | 'P-384' | 'P-521';
|
|
23
|
+
export type PairAlgorithm = 'ECDSA' | 'ECDH';
|
|
24
|
+
|
|
25
|
+
export type Recipe =
|
|
26
|
+
| { kind: 'random'; encoding: Encoding; bytes: number }
|
|
27
|
+
| { kind: 'aes'; size: AesSize }
|
|
28
|
+
| { kind: 'pair'; algorithm: PairAlgorithm; curve: Curve };
|
|
29
|
+
|
|
30
|
+
/** A value, and for a key pair its public key, which is saved as a secret of its own. */
|
|
31
|
+
export type Generated = { value: string; publicKey?: string };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a write says about how its value was made, and what a listing says
|
|
35
|
+
* of a key's current value: its recipe, and for a key pair the two keys it
|
|
36
|
+
* wrote together, which only a write of both at once makes.
|
|
37
|
+
*/
|
|
38
|
+
export type Generation = { recipe: string; pair?: KeyPairNames };
|
|
39
|
+
export type KeyPairNames = { private: string; public: string };
|
|
40
|
+
|
|
41
|
+
export const MIN_BYTES = 1;
|
|
42
|
+
export const MAX_BYTES = 256;
|
|
43
|
+
/** Longer than any recipe there is: `bytes:256:base64url` is 19. */
|
|
44
|
+
export const MAX_RECIPE_LENGTH = 32;
|
|
45
|
+
export const DEFAULT_RECIPE: Recipe = { kind: 'random', encoding: 'base62', bytes: 32 };
|
|
46
|
+
|
|
47
|
+
export const ENCODINGS: { id: Encoding; name: string }[] = [
|
|
48
|
+
{ id: 'base62', name: 'Letters and digits' },
|
|
49
|
+
{ id: 'hex', name: 'Hex' },
|
|
50
|
+
{ id: 'base64', name: 'Base64' },
|
|
51
|
+
{ id: 'base64url', name: 'Base64 URL' },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export const CURVES: readonly Curve[] = ['P-256', 'P-384', 'P-521'];
|
|
55
|
+
|
|
56
|
+
/** Strongest first, as a new key should be. */
|
|
57
|
+
export const PAIRS: { algorithm: PairAlgorithm; curve: Curve }[] = [
|
|
58
|
+
{ algorithm: 'ECDSA', curve: 'P-521' },
|
|
59
|
+
{ algorithm: 'ECDSA', curve: 'P-384' },
|
|
60
|
+
{ algorithm: 'ECDSA', curve: 'P-256' },
|
|
61
|
+
{ algorithm: 'ECDH', curve: 'P-521' },
|
|
62
|
+
{ algorithm: 'ECDH', curve: 'P-384' },
|
|
63
|
+
{ algorithm: 'ECDH', curve: 'P-256' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export const RECIPE_SYNTAX = 'bytes:<1-256>[:base62|hex|base64|base64url], aes:<128|256>, ecdsa:<p-256|p-384|p-521> or ecdh:<curve>';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The recipe a text names, in any case: `bytes:32` is `bytes:32:base62`,
|
|
70
|
+
* `ECDSA:P-256` is `ecdsa:p-256`. Throws, saying what is wrong, for any
|
|
71
|
+
* other text.
|
|
72
|
+
*/
|
|
73
|
+
export function parseRecipe(text: string): Recipe {
|
|
74
|
+
const parts = text.trim().toLowerCase().split(':');
|
|
75
|
+
const [kind, size, encoding, ...rest] = parts;
|
|
76
|
+
const wrong = (what: string) => new Error(`${what}: a recipe is ${RECIPE_SYNTAX}`);
|
|
77
|
+
if (text.length > MAX_RECIPE_LENGTH) throw wrong(`"${text.slice(0, MAX_RECIPE_LENGTH)}…" is too long`);
|
|
78
|
+
switch (kind) {
|
|
79
|
+
case 'bytes': {
|
|
80
|
+
if (rest.length > 0) break;
|
|
81
|
+
const bytes = wholeNumber(size);
|
|
82
|
+
if (bytes === null || bytes < MIN_BYTES || bytes > MAX_BYTES) {
|
|
83
|
+
throw wrong(`bytes takes a length from ${MIN_BYTES} to ${MAX_BYTES}, not "${size ?? ''}"`);
|
|
84
|
+
}
|
|
85
|
+
const named = encoding ?? 'base62';
|
|
86
|
+
if (!ENCODINGS.some((each) => each.id === named)) {
|
|
87
|
+
throw wrong(`bytes are written as base62, hex, base64 or base64url, not "${named}"`);
|
|
88
|
+
}
|
|
89
|
+
return { kind: 'random', bytes, encoding: named as Encoding };
|
|
90
|
+
}
|
|
91
|
+
case 'aes': {
|
|
92
|
+
if (encoding !== undefined) break;
|
|
93
|
+
if (size !== '128' && size !== '256') throw wrong(`an AES key is 128 or 256 bits, not "${size ?? ''}"`);
|
|
94
|
+
return { kind: 'aes', size: Number(size) as AesSize };
|
|
95
|
+
}
|
|
96
|
+
case 'ecdsa':
|
|
97
|
+
case 'ecdh': {
|
|
98
|
+
if (encoding !== undefined) break;
|
|
99
|
+
const curve = CURVES.find((each) => each.toLowerCase() === size);
|
|
100
|
+
if (curve === undefined) throw wrong(`${kind} takes the curve p-256, p-384 or p-521, not "${size ?? ''}"`);
|
|
101
|
+
return { kind: 'pair', algorithm: kind === 'ecdsa' ? 'ECDSA' : 'ECDH', curve };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw wrong(`"${text}" is no recipe`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What is wrong with a recipe's text, or null for a recipe. */
|
|
108
|
+
export function recipeProblem(text: string): string | null {
|
|
109
|
+
try {
|
|
110
|
+
parseRecipe(text);
|
|
111
|
+
return null;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return (error as Error).message;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A recipe as it is stored and shown: lowercase, every part named. */
|
|
118
|
+
export function formatRecipe(recipe: Recipe): string {
|
|
119
|
+
switch (recipe.kind) {
|
|
120
|
+
case 'random':
|
|
121
|
+
return `bytes:${recipe.bytes}:${recipe.encoding}`;
|
|
122
|
+
case 'aes':
|
|
123
|
+
return `aes:${recipe.size}`;
|
|
124
|
+
case 'pair':
|
|
125
|
+
return `${recipe.algorithm.toLowerCase()}:${recipe.curve.toLowerCase()}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A recipe in words, for a confirmation or a listing: "32 random bytes as 64 hex characters". */
|
|
130
|
+
export function describeRecipe(recipe: Recipe): string {
|
|
131
|
+
switch (recipe.kind) {
|
|
132
|
+
case 'random': {
|
|
133
|
+
const characters = { base62: 'letters and digits', hex: 'hex characters', base64: 'base64 characters', base64url: 'base64 URL characters' };
|
|
134
|
+
const bytes = `${recipe.bytes} random ${recipe.bytes === 1 ? 'byte' : 'bytes'}`;
|
|
135
|
+
return `${bytes} as ${encodedLength(recipe.encoding, recipe.bytes)} ${characters[recipe.encoding]}`;
|
|
136
|
+
}
|
|
137
|
+
case 'aes':
|
|
138
|
+
return `a ${recipe.size}-bit AES-GCM key, raw, base64`;
|
|
139
|
+
case 'pair':
|
|
140
|
+
return `an ${recipe.algorithm} ${recipe.curve} key pair: the private key PKCS #8, the public key SPKI, both base64`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* How many characters carry `bytes` of entropy. Hex and base64 encode the
|
|
146
|
+
* bytes themselves; base62 draws whole characters, each worth log2(62) bits,
|
|
147
|
+
* so it takes as many as reach the same number of bits: 43 for 32 bytes.
|
|
148
|
+
* Base64 pads to a multiple of four; base64 URL leaves the padding off.
|
|
149
|
+
*/
|
|
150
|
+
export function encodedLength(encoding: Encoding, bytes: number): number {
|
|
151
|
+
switch (encoding) {
|
|
152
|
+
case 'hex':
|
|
153
|
+
return bytes * 2;
|
|
154
|
+
case 'base64':
|
|
155
|
+
return Math.ceil(bytes / 3) * 4;
|
|
156
|
+
case 'base64url':
|
|
157
|
+
return Math.ceil((bytes * 8) / 6);
|
|
158
|
+
case 'base62':
|
|
159
|
+
return Math.ceil((bytes * 8) / Math.log2(62));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
164
|
+
/**
|
|
165
|
+
* The largest multiple of 62 a byte reaches: 248. A byte below it is taken
|
|
166
|
+
* modulo 62, and each of the 62 characters has four bytes that make it. The
|
|
167
|
+
* eight bytes from 248 up are thrown away, as taking them would make the
|
|
168
|
+
* first eight characters a little likelier than the rest.
|
|
169
|
+
*/
|
|
170
|
+
const BASE62_LIMIT = 256 - (256 % 62);
|
|
171
|
+
|
|
172
|
+
export type RandomBytes = (bytes: Uint8Array<ArrayBuffer>) => Uint8Array<ArrayBuffer>;
|
|
173
|
+
const randomBytes: RandomBytes = (bytes) => webCrypto().getRandomValues(bytes);
|
|
174
|
+
|
|
175
|
+
/** `length` characters of base62, each drawn uniformly by rejecting the bytes 248 to 255. */
|
|
176
|
+
export function base62(length: number, random: RandomBytes = randomBytes): string {
|
|
177
|
+
let out = '';
|
|
178
|
+
while (out.length < length) {
|
|
179
|
+
// About 3% of bytes are rejected, so a few spare bytes almost always finish it in one draw.
|
|
180
|
+
for (const byte of random(new Uint8Array(length - out.length + 8))) {
|
|
181
|
+
if (byte >= BASE62_LIMIT) continue;
|
|
182
|
+
out += BASE62[byte % 62];
|
|
183
|
+
if (out.length === length) break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function randomValue(encoding: Encoding, bytes: number, random: RandomBytes = randomBytes): string {
|
|
190
|
+
if (encoding === 'base62') return base62(encodedLength('base62', bytes), random);
|
|
191
|
+
const drawn = random(new Uint8Array(bytes));
|
|
192
|
+
if (encoding === 'hex') return [...drawn].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
193
|
+
const base64 = toBase64(drawn);
|
|
194
|
+
return encoding === 'base64'
|
|
195
|
+
? base64
|
|
196
|
+
: base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A value made by the recipe, with the platform's CSPRNG. */
|
|
200
|
+
export async function generate(recipe: Recipe): Promise<Generated> {
|
|
201
|
+
switch (recipe.kind) {
|
|
202
|
+
case 'random':
|
|
203
|
+
return { value: randomValue(recipe.encoding, recipe.bytes) };
|
|
204
|
+
case 'aes': {
|
|
205
|
+
const key = await subtle().generateKey({ name: 'AES-GCM', length: recipe.size }, true, [
|
|
206
|
+
'encrypt',
|
|
207
|
+
'decrypt',
|
|
208
|
+
]);
|
|
209
|
+
return { value: toBase64(await subtle().exportKey('raw', key)) };
|
|
210
|
+
}
|
|
211
|
+
case 'pair': {
|
|
212
|
+
const usages: KeyUsage[] = recipe.algorithm === 'ECDSA' ? ['sign', 'verify'] : ['deriveBits'];
|
|
213
|
+
const pair = await subtle().generateKey(
|
|
214
|
+
{ name: recipe.algorithm, namedCurve: recipe.curve },
|
|
215
|
+
true,
|
|
216
|
+
usages,
|
|
217
|
+
);
|
|
218
|
+
return {
|
|
219
|
+
value: toBase64(await subtle().exportKey('pkcs8', pair.privateKey)),
|
|
220
|
+
publicKey: toBase64(await subtle().exportKey('spki', pair.publicKey)),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What a write of generated values sends, by key: each value with how it
|
|
228
|
+
* was made, and for a regenerate the version it replaces, which the server
|
|
229
|
+
* holds it to.
|
|
230
|
+
*/
|
|
231
|
+
export type GeneratedWrite = Record<string, { value: string; generated: Generation; replaces?: string }>;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A value made by the recipe for `key`, as a write sends it, and for a key
|
|
235
|
+
* pair its public key beside it, under `publicKey` or the name that follows
|
|
236
|
+
* from the private key's (`publicKeyName`). The two halves name each other.
|
|
237
|
+
*/
|
|
238
|
+
export async function generateWrite(key: string, recipe: Recipe, publicKey?: string): Promise<GeneratedWrite> {
|
|
239
|
+
const text = formatRecipe(recipe);
|
|
240
|
+
const made = await generate(recipe);
|
|
241
|
+
if (recipe.kind !== 'pair') return { [key]: { value: made.value, generated: { recipe: text } } };
|
|
242
|
+
const pair = { private: key, public: publicKey ?? publicKeyName(key) };
|
|
243
|
+
return {
|
|
244
|
+
[pair.private]: { value: made.value, generated: { recipe: text, pair } },
|
|
245
|
+
[pair.public]: { value: made.publicKey!, generated: { recipe: text, pair } },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The next value of a key by the recipe its current one was made by, as a
|
|
251
|
+
* listing says it: a key pair's either half makes the pair again, whole,
|
|
252
|
+
* under the names its halves have now. Each key says the version it
|
|
253
|
+
* replaces, by the id `versionIdOf` gives as listed, and the server refuses
|
|
254
|
+
* the write (409) if any has changed since: renamed, archived, typed over
|
|
255
|
+
* or regenerated meanwhile. A regenerate addresses keys by name, and
|
|
256
|
+
* without that it could write a name that no longer holds the pair.
|
|
257
|
+
*/
|
|
258
|
+
export async function regenerateWrite(
|
|
259
|
+
key: string,
|
|
260
|
+
generated: Generation,
|
|
261
|
+
versionIdOf: (key: string) => string | null,
|
|
262
|
+
): Promise<GeneratedWrite> {
|
|
263
|
+
const recipe = parseRecipe(generated.recipe);
|
|
264
|
+
if (recipe.kind === 'pair' && generated.pair === undefined) {
|
|
265
|
+
throw new Error(`${key}: ${generated.recipe} makes a key pair, and no pair is named`);
|
|
266
|
+
}
|
|
267
|
+
const write = recipe.kind === 'pair' ? await generateWrite(generated.pair!.private, recipe, generated.pair!.public) : await generateWrite(key, recipe);
|
|
268
|
+
for (const [name, entry] of Object.entries(write)) {
|
|
269
|
+
const version = versionIdOf(name);
|
|
270
|
+
if (version === null) throw new Error(`${name} is not listed with a version to regenerate`);
|
|
271
|
+
entry.replaces = version;
|
|
272
|
+
}
|
|
273
|
+
return write;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The name a key pair's public key gets beside its private key's:
|
|
278
|
+
* `JWT_PRIVATE_KEY` gives `JWT_PUBLIC_KEY`, any other name gains `_PUBLIC`.
|
|
279
|
+
*/
|
|
280
|
+
export function publicKeyName(privateName: string): string {
|
|
281
|
+
const name = privateName.trim();
|
|
282
|
+
if (name === '') return '';
|
|
283
|
+
if (/PRIVATE/.test(name)) return name.replace(/PRIVATE/g, 'PUBLIC');
|
|
284
|
+
if (/private/.test(name)) return name.replace(/private/g, 'public');
|
|
285
|
+
return `${name}_PUBLIC`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function wholeNumber(text: string | undefined): number | null {
|
|
289
|
+
return text !== undefined && /^\d{1,4}$/.test(text) ? Number(text) : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Web Crypto's keys need a secure context: HTTPS, or the machine's own address. */
|
|
293
|
+
function subtle(): SubtleCrypto {
|
|
294
|
+
if (globalThis.crypto?.subtle === undefined) {
|
|
295
|
+
throw new Error('This page is not served over HTTPS, so the browser makes no keys here.');
|
|
296
|
+
}
|
|
297
|
+
return globalThis.crypto.subtle;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function webCrypto(): Crypto {
|
|
301
|
+
if (globalThis.crypto?.getRandomValues === undefined) throw new Error('This runtime has no Web Crypto to draw random bytes from.');
|
|
302
|
+
return globalThis.crypto;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function toBase64(bytes: ArrayBuffer | Uint8Array): string {
|
|
306
|
+
let binary = '';
|
|
307
|
+
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte);
|
|
308
|
+
return btoa(binary);
|
|
309
|
+
}
|
package/src/schemas.ts
CHANGED
|
@@ -4,6 +4,21 @@ import { INSTANCE_ROLE_NAMES, type InstanceRole } from './access.ts';
|
|
|
4
4
|
import { PROJECT_PAGES } from './pages.ts';
|
|
5
5
|
|
|
6
6
|
export const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
7
|
+
/**
|
|
8
|
+
* Your personal config of an environment, in a path: `market/dev@me`
|
|
9
|
+
* (docs/design/forking.md). A read falls back to `market/dev` when you have
|
|
10
|
+
* none; a write never does.
|
|
11
|
+
*/
|
|
12
|
+
export const PERSONAL_SUFFIX = '@me';
|
|
13
|
+
|
|
14
|
+
/** `dev@me` as the environment it is a personal config of, `dev`; null for any other slug. */
|
|
15
|
+
export function personalOf(environment: string): string | null {
|
|
16
|
+
return environment.endsWith(PERSONAL_SUFFIX) ? environment.slice(0, -PERSONAL_SUFFIX.length) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** An environment as a path names it: a slug, or `<slug>@me` for your personal config of it. */
|
|
20
|
+
export const environmentPath = z.string().refine((value) => slug.safeParse(personalOf(value) ?? value).success, 'not a valid environment');
|
|
21
|
+
|
|
7
22
|
/** An environment's slug: any slug but the name of one of its project's pages. */
|
|
8
23
|
export const environmentSlug = slug.refine((value) => !(PROJECT_PAGES as readonly string[]).includes(value), {
|
|
9
24
|
error: (issue) => `"${String(issue.input)}" is taken by a project's page, /projects/<project>/${String(issue.input)}: an environment cannot have it`,
|
package/src/vault.ts
CHANGED
|
@@ -163,6 +163,12 @@ export type RefusalCode =
|
|
|
163
163
|
| 'bulk_limit'
|
|
164
164
|
/** The wrapped key does not belong to the secret it was presented as, or a reference is not the vault's. */
|
|
165
165
|
| 'bad_claim'
|
|
166
|
+
/**
|
|
167
|
+
* A person asked for values to be handed back, where people get stand-ins
|
|
168
|
+
* (`people: 'stand-ins'`): their apps use values through the credential
|
|
169
|
+
* proxy instead, and only service accounts read them.
|
|
170
|
+
*/
|
|
171
|
+
| 'stand_ins'
|
|
166
172
|
/**
|
|
167
173
|
* The project or environment was deleted for good: nothing under it is
|
|
168
174
|
* opened or wrapped again, whatever grant would cover it.
|
|
@@ -170,6 +176,12 @@ export type RefusalCode =
|
|
|
170
176
|
| 'deleted'
|
|
171
177
|
/** The reference read through was ended: broken, or replaced by a value. */
|
|
172
178
|
| 'ended'
|
|
179
|
+
/**
|
|
180
|
+
* A personal config's value, which only its owner opens or writes, and
|
|
181
|
+
* only while they read the environment it is a config of: no grant or
|
|
182
|
+
* role reaches it, an Owner's included. Nor does any reference.
|
|
183
|
+
*/
|
|
184
|
+
| 'personal'
|
|
173
185
|
/** Not allowed to manage access, members or the log. */
|
|
174
186
|
| 'not_allowed'
|
|
175
187
|
/**
|
|
@@ -222,8 +234,21 @@ export type WrappedKey = { kekProvider: string; kekId: string; kekVersion: strin
|
|
|
222
234
|
*/
|
|
223
235
|
export type KeyChecks = { current: { kekProvider: string; kekId: string }; checks: (WrappedKey & { seq: number })[] };
|
|
224
236
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Why someone reads: shown in the log, and the same rules apply to each. A
|
|
239
|
+
* fork reads to `copy`. The credential proxy reads to `use`, a value it
|
|
240
|
+
* injects into a request and never hands back, or to `expose`, one the
|
|
241
|
+
* deployment lets people's apps hold. Where people get stand-ins
|
|
242
|
+
* (`people: 'stand-ins'`), a person opens nothing to `reveal`, `run` or
|
|
243
|
+
* `compare` (`HANDED_BACK`).
|
|
244
|
+
*/
|
|
245
|
+
export type Purpose = 'reveal' | 'run' | 'compare' | 'copy' | 'use' | 'expose';
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* What a person never opens where people get stand-ins: values handed back
|
|
249
|
+
* to them, and a dry run's `compare`, which tells them whether a guess was right.
|
|
250
|
+
*/
|
|
251
|
+
export const HANDED_BACK: readonly Purpose[] = ['reveal', 'run', 'compare'];
|
|
227
252
|
|
|
228
253
|
/** Ties the vault's entries to the app's request and audit rows. */
|
|
229
254
|
type Correlation = {
|
|
@@ -255,9 +280,11 @@ export type UnwrapInput = Correlation & Through & {
|
|
|
255
280
|
/**
|
|
256
281
|
* `via` reads a source's current version through a reference: the vault
|
|
257
282
|
* checks the reader's grants on the holder's environment, as its
|
|
258
|
-
* `reference.create` entry names it, not on the source's.
|
|
283
|
+
* `reference.create` entry names it, not on the source's. `through`, an
|
|
284
|
+
* environment's id, is the fork an inherited value is read in: the vault
|
|
285
|
+
* logs the read there too, and decides on the value's own environment.
|
|
259
286
|
*/
|
|
260
|
-
items: { secretVersionId: string; via?: Via }[];
|
|
287
|
+
items: { secretVersionId: string; via?: Via; through?: string }[];
|
|
261
288
|
};
|
|
262
289
|
|
|
263
290
|
/** A reference, as a read names it: its id, and the seq of the vault's `reference.create` entry. */
|