@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.
@@ -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 top-level string matched exactly. The event that started the run may
7
- * be any of several: a list. The profile sets the minimum: the server
8
- * refuses a binding that lacks a claim its profile requires, from the UI,
9
- * the CLI or the API alike. Say a service is bound to `ci.yml` of
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
- /** What makes a binding, checked: its profile, its issuer as a URL, and its claims, sorted. */
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. Throws `BindingInvalid`.
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
- checkFetchUrl(issuer, 'the issuer', { allowLoopback: options.allowLoopback, path: true, query: false });
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 profiles, two readings:
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[name];
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);
@@ -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 { 'coffre:project': ctx.projectId, 'coffre:environment': ctx.environmentId, 'coffre:secret': ctx.secretId };
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
- /** Why someone reads: shown in the log, and the same rules apply to each. A fork reads to `copy`. */
226
- export type Purpose = 'reveal' | 'run' | 'compare' | 'copy';
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. */