@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.
@@ -0,0 +1,126 @@
1
+ //#region src/recipe.d.ts
2
+ /**
3
+ * How a secret's value is made, when a client makes it rather than a person
4
+ * typing it: random strings, AES keys and EC key pairs, from Web Crypto
5
+ * alone, the same in the browser and in the CLI.
6
+ *
7
+ * A recipe is short text, `bytes:32:hex`, which the CLI takes as
8
+ * `--generate` and the server stores beside the version it made, so that
9
+ * `coffre regenerate` and the page's Regenerate make the next one the same
10
+ * way. The server checks a recipe's shape and never makes a value: a value
11
+ * is made where it is sent from, and nothing here talks to the server.
12
+ *
13
+ * ```
14
+ * bytes:<1-256>[:base62|hex|base64|base64url] random bytes, base62 unless named
15
+ * aes:<128|256> an AES-GCM key: the raw key, base64
16
+ * ecdsa:<p-256|p-384|p-521> a key pair: PKCS #8 private, SPKI public, base64
17
+ * ecdh:<p-256|p-384|p-521>
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
+ export type Recipe = {
25
+ kind: 'random';
26
+ encoding: Encoding;
27
+ bytes: number;
28
+ } | {
29
+ kind: 'aes';
30
+ size: AesSize;
31
+ } | {
32
+ kind: 'pair';
33
+ algorithm: PairAlgorithm;
34
+ curve: Curve;
35
+ };
36
+ /** A value, and for a key pair its public key, which is saved as a secret of its own. */
37
+ export type Generated = {
38
+ value: string;
39
+ publicKey?: string;
40
+ };
41
+ /**
42
+ * What a write says about how its value was made, and what a listing says
43
+ * of a key's current value: its recipe, and for a key pair the two keys it
44
+ * wrote together, which only a write of both at once makes.
45
+ */
46
+ export type Generation = {
47
+ recipe: string;
48
+ pair?: KeyPairNames;
49
+ };
50
+ export type KeyPairNames = {
51
+ private: string;
52
+ public: string;
53
+ };
54
+ export declare const MIN_BYTES = 1;
55
+ export declare const MAX_BYTES = 256;
56
+ /** Longer than any recipe there is: `bytes:256:base64url` is 19. */
57
+ export declare const MAX_RECIPE_LENGTH = 32;
58
+ export declare const DEFAULT_RECIPE: Recipe;
59
+ export declare const ENCODINGS: {
60
+ id: Encoding;
61
+ name: string;
62
+ }[];
63
+ export declare const CURVES: readonly Curve[];
64
+ /** Strongest first, as a new key should be. */
65
+ export declare const PAIRS: {
66
+ algorithm: PairAlgorithm;
67
+ curve: Curve;
68
+ }[];
69
+ export declare const RECIPE_SYNTAX = "bytes:<1-256>[:base62|hex|base64|base64url], aes:<128|256>, ecdsa:<p-256|p-384|p-521> or ecdh:<curve>";
70
+ /**
71
+ * The recipe a text names, in any case: `bytes:32` is `bytes:32:base62`,
72
+ * `ECDSA:P-256` is `ecdsa:p-256`. Throws, saying what is wrong, for any
73
+ * other text.
74
+ */
75
+ export declare function parseRecipe(text: string): Recipe;
76
+ /** What is wrong with a recipe's text, or null for a recipe. */
77
+ export declare function recipeProblem(text: string): string | null;
78
+ /** A recipe as it is stored and shown: lowercase, every part named. */
79
+ export declare function formatRecipe(recipe: Recipe): string;
80
+ /** A recipe in words, for a confirmation or a listing: "32 random bytes as 64 hex characters". */
81
+ export declare function describeRecipe(recipe: Recipe): string;
82
+ /**
83
+ * How many characters carry `bytes` of entropy. Hex and base64 encode the
84
+ * bytes themselves; base62 draws whole characters, each worth log2(62) bits,
85
+ * so it takes as many as reach the same number of bits: 43 for 32 bytes.
86
+ * Base64 pads to a multiple of four; base64 URL leaves the padding off.
87
+ */
88
+ export declare function encodedLength(encoding: Encoding, bytes: number): number;
89
+ export type RandomBytes = (bytes: Uint8Array<ArrayBuffer>) => Uint8Array<ArrayBuffer>;
90
+ /** `length` characters of base62, each drawn uniformly by rejecting the bytes 248 to 255. */
91
+ export declare function base62(length: number, random?: RandomBytes): string;
92
+ export declare function randomValue(encoding: Encoding, bytes: number, random?: RandomBytes): string;
93
+ /** A value made by the recipe, with the platform's CSPRNG. */
94
+ export declare function generate(recipe: Recipe): Promise<Generated>;
95
+ /**
96
+ * What a write of generated values sends, by key: each value with how it
97
+ * was made, and for a regenerate the version it replaces, which the server
98
+ * holds it to.
99
+ */
100
+ export type GeneratedWrite = Record<string, {
101
+ value: string;
102
+ generated: Generation;
103
+ replaces?: string;
104
+ }>;
105
+ /**
106
+ * A value made by the recipe for `key`, as a write sends it, and for a key
107
+ * pair its public key beside it, under `publicKey` or the name that follows
108
+ * from the private key's (`publicKeyName`). The two halves name each other.
109
+ */
110
+ export declare function generateWrite(key: string, recipe: Recipe, publicKey?: string): Promise<GeneratedWrite>;
111
+ /**
112
+ * The next value of a key by the recipe its current one was made by, as a
113
+ * listing says it: a key pair's either half makes the pair again, whole,
114
+ * under the names its halves have now. Each key says the version it
115
+ * replaces, by the id `versionIdOf` gives as listed, and the server refuses
116
+ * the write (409) if any has changed since: renamed, archived, typed over
117
+ * or regenerated meanwhile. A regenerate addresses keys by name, and
118
+ * without that it could write a name that no longer holds the pair.
119
+ */
120
+ export declare function regenerateWrite(key: string, generated: Generation, versionIdOf: (key: string) => string | null): Promise<GeneratedWrite>;
121
+ /**
122
+ * The name a key pair's public key gets beside its private key's:
123
+ * `JWT_PRIVATE_KEY` gives `JWT_PUBLIC_KEY`, any other name gains `_PUBLIC`.
124
+ */
125
+ export declare function publicKeyName(privateName: string): string;
126
+ //#endregion
package/dist/recipe.js ADDED
@@ -0,0 +1,281 @@
1
+ //#region src/recipe.ts
2
+ const MIN_BYTES = 1;
3
+ const MAX_BYTES = 256;
4
+ /** Longer than any recipe there is: `bytes:256:base64url` is 19. */
5
+ const MAX_RECIPE_LENGTH = 32;
6
+ const DEFAULT_RECIPE = {
7
+ kind: "random",
8
+ encoding: "base62",
9
+ bytes: 32
10
+ };
11
+ const ENCODINGS = [
12
+ {
13
+ id: "base62",
14
+ name: "Letters and digits"
15
+ },
16
+ {
17
+ id: "hex",
18
+ name: "Hex"
19
+ },
20
+ {
21
+ id: "base64",
22
+ name: "Base64"
23
+ },
24
+ {
25
+ id: "base64url",
26
+ name: "Base64 URL"
27
+ }
28
+ ];
29
+ const CURVES = [
30
+ "P-256",
31
+ "P-384",
32
+ "P-521"
33
+ ];
34
+ /** Strongest first, as a new key should be. */
35
+ const PAIRS = [
36
+ {
37
+ algorithm: "ECDSA",
38
+ curve: "P-521"
39
+ },
40
+ {
41
+ algorithm: "ECDSA",
42
+ curve: "P-384"
43
+ },
44
+ {
45
+ algorithm: "ECDSA",
46
+ curve: "P-256"
47
+ },
48
+ {
49
+ algorithm: "ECDH",
50
+ curve: "P-521"
51
+ },
52
+ {
53
+ algorithm: "ECDH",
54
+ curve: "P-384"
55
+ },
56
+ {
57
+ algorithm: "ECDH",
58
+ curve: "P-256"
59
+ }
60
+ ];
61
+ const RECIPE_SYNTAX = "bytes:<1-256>[:base62|hex|base64|base64url], aes:<128|256>, ecdsa:<p-256|p-384|p-521> or ecdh:<curve>";
62
+ /**
63
+ * The recipe a text names, in any case: `bytes:32` is `bytes:32:base62`,
64
+ * `ECDSA:P-256` is `ecdsa:p-256`. Throws, saying what is wrong, for any
65
+ * other text.
66
+ */
67
+ function parseRecipe(text) {
68
+ const [kind, size, encoding, ...rest] = text.trim().toLowerCase().split(":");
69
+ const wrong = (what) => /* @__PURE__ */ new Error(`${what}: a recipe is ${RECIPE_SYNTAX}`);
70
+ if (text.length > 32) throw wrong(`"${text.slice(0, 32)}…" is too long`);
71
+ switch (kind) {
72
+ case "bytes": {
73
+ if (rest.length > 0) break;
74
+ const bytes = wholeNumber(size);
75
+ if (bytes === null || bytes < 1 || bytes > 256) throw wrong(`bytes takes a length from 1 to 256, not "${size ?? ""}"`);
76
+ const named = encoding ?? "base62";
77
+ if (!ENCODINGS.some((each) => each.id === named)) throw wrong(`bytes are written as base62, hex, base64 or base64url, not "${named}"`);
78
+ return {
79
+ kind: "random",
80
+ bytes,
81
+ encoding: named
82
+ };
83
+ }
84
+ case "aes":
85
+ if (encoding !== void 0) break;
86
+ if (size !== "128" && size !== "256") throw wrong(`an AES key is 128 or 256 bits, not "${size ?? ""}"`);
87
+ return {
88
+ kind: "aes",
89
+ size: Number(size)
90
+ };
91
+ case "ecdsa":
92
+ case "ecdh": {
93
+ if (encoding !== void 0) break;
94
+ const curve = CURVES.find((each) => each.toLowerCase() === size);
95
+ if (curve === void 0) throw wrong(`${kind} takes the curve p-256, p-384 or p-521, not "${size ?? ""}"`);
96
+ return {
97
+ kind: "pair",
98
+ algorithm: kind === "ecdsa" ? "ECDSA" : "ECDH",
99
+ curve
100
+ };
101
+ }
102
+ }
103
+ throw wrong(`"${text}" is no recipe`);
104
+ }
105
+ /** What is wrong with a recipe's text, or null for a recipe. */
106
+ function recipeProblem(text) {
107
+ try {
108
+ parseRecipe(text);
109
+ return null;
110
+ } catch (error) {
111
+ return error.message;
112
+ }
113
+ }
114
+ /** A recipe as it is stored and shown: lowercase, every part named. */
115
+ function formatRecipe(recipe) {
116
+ switch (recipe.kind) {
117
+ case "random": return `bytes:${recipe.bytes}:${recipe.encoding}`;
118
+ case "aes": return `aes:${recipe.size}`;
119
+ case "pair": return `${recipe.algorithm.toLowerCase()}:${recipe.curve.toLowerCase()}`;
120
+ }
121
+ }
122
+ /** A recipe in words, for a confirmation or a listing: "32 random bytes as 64 hex characters". */
123
+ function describeRecipe(recipe) {
124
+ switch (recipe.kind) {
125
+ case "random": return `${`${recipe.bytes} random ${recipe.bytes === 1 ? "byte" : "bytes"}`} as ${encodedLength(recipe.encoding, recipe.bytes)} ${{
126
+ base62: "letters and digits",
127
+ hex: "hex characters",
128
+ base64: "base64 characters",
129
+ base64url: "base64 URL characters"
130
+ }[recipe.encoding]}`;
131
+ case "aes": return `a ${recipe.size}-bit AES-GCM key, raw, base64`;
132
+ case "pair": return `an ${recipe.algorithm} ${recipe.curve} key pair: the private key PKCS #8, the public key SPKI, both base64`;
133
+ }
134
+ }
135
+ /**
136
+ * How many characters carry `bytes` of entropy. Hex and base64 encode the
137
+ * bytes themselves; base62 draws whole characters, each worth log2(62) bits,
138
+ * so it takes as many as reach the same number of bits: 43 for 32 bytes.
139
+ * Base64 pads to a multiple of four; base64 URL leaves the padding off.
140
+ */
141
+ function encodedLength(encoding, bytes) {
142
+ switch (encoding) {
143
+ case "hex": return bytes * 2;
144
+ case "base64": return Math.ceil(bytes / 3) * 4;
145
+ case "base64url": return Math.ceil(bytes * 8 / 6);
146
+ case "base62": return Math.ceil(bytes * 8 / Math.log2(62));
147
+ }
148
+ }
149
+ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
150
+ /**
151
+ * The largest multiple of 62 a byte reaches: 248. A byte below it is taken
152
+ * modulo 62, and each of the 62 characters has four bytes that make it. The
153
+ * eight bytes from 248 up are thrown away, as taking them would make the
154
+ * first eight characters a little likelier than the rest.
155
+ */
156
+ const BASE62_LIMIT = 248;
157
+ const randomBytes = (bytes) => webCrypto().getRandomValues(bytes);
158
+ /** `length` characters of base62, each drawn uniformly by rejecting the bytes 248 to 255. */
159
+ function base62(length, random = randomBytes) {
160
+ let out = "";
161
+ while (out.length < length) for (const byte of random(new Uint8Array(length - out.length + 8))) {
162
+ if (byte >= BASE62_LIMIT) continue;
163
+ out += BASE62[byte % 62];
164
+ if (out.length === length) break;
165
+ }
166
+ return out;
167
+ }
168
+ function randomValue(encoding, bytes, random = randomBytes) {
169
+ if (encoding === "base62") return base62(encodedLength("base62", bytes), random);
170
+ const drawn = random(new Uint8Array(bytes));
171
+ if (encoding === "hex") return [...drawn].map((byte) => byte.toString(16).padStart(2, "0")).join("");
172
+ const base64 = toBase64(drawn);
173
+ return encoding === "base64" ? base64 : base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
174
+ }
175
+ /** A value made by the recipe, with the platform's CSPRNG. */
176
+ async function generate(recipe) {
177
+ switch (recipe.kind) {
178
+ case "random": return { value: randomValue(recipe.encoding, recipe.bytes) };
179
+ case "aes": {
180
+ const key = await subtle().generateKey({
181
+ name: "AES-GCM",
182
+ length: recipe.size
183
+ }, true, ["encrypt", "decrypt"]);
184
+ return { value: toBase64(await subtle().exportKey("raw", key)) };
185
+ }
186
+ case "pair": {
187
+ const usages = recipe.algorithm === "ECDSA" ? ["sign", "verify"] : ["deriveBits"];
188
+ const pair = await subtle().generateKey({
189
+ name: recipe.algorithm,
190
+ namedCurve: recipe.curve
191
+ }, true, usages);
192
+ return {
193
+ value: toBase64(await subtle().exportKey("pkcs8", pair.privateKey)),
194
+ publicKey: toBase64(await subtle().exportKey("spki", pair.publicKey))
195
+ };
196
+ }
197
+ }
198
+ }
199
+ /**
200
+ * A value made by the recipe for `key`, as a write sends it, and for a key
201
+ * pair its public key beside it, under `publicKey` or the name that follows
202
+ * from the private key's (`publicKeyName`). The two halves name each other.
203
+ */
204
+ async function generateWrite(key, recipe, publicKey) {
205
+ const text = formatRecipe(recipe);
206
+ const made = await generate(recipe);
207
+ if (recipe.kind !== "pair") return { [key]: {
208
+ value: made.value,
209
+ generated: { recipe: text }
210
+ } };
211
+ const pair = {
212
+ private: key,
213
+ public: publicKey ?? publicKeyName(key)
214
+ };
215
+ return {
216
+ [pair.private]: {
217
+ value: made.value,
218
+ generated: {
219
+ recipe: text,
220
+ pair
221
+ }
222
+ },
223
+ [pair.public]: {
224
+ value: made.publicKey,
225
+ generated: {
226
+ recipe: text,
227
+ pair
228
+ }
229
+ }
230
+ };
231
+ }
232
+ /**
233
+ * The next value of a key by the recipe its current one was made by, as a
234
+ * listing says it: a key pair's either half makes the pair again, whole,
235
+ * under the names its halves have now. Each key says the version it
236
+ * replaces, by the id `versionIdOf` gives as listed, and the server refuses
237
+ * the write (409) if any has changed since: renamed, archived, typed over
238
+ * or regenerated meanwhile. A regenerate addresses keys by name, and
239
+ * without that it could write a name that no longer holds the pair.
240
+ */
241
+ async function regenerateWrite(key, generated, versionIdOf) {
242
+ const recipe = parseRecipe(generated.recipe);
243
+ if (recipe.kind === "pair" && generated.pair === void 0) throw new Error(`${key}: ${generated.recipe} makes a key pair, and no pair is named`);
244
+ const write = recipe.kind === "pair" ? await generateWrite(generated.pair.private, recipe, generated.pair.public) : await generateWrite(key, recipe);
245
+ for (const [name, entry] of Object.entries(write)) {
246
+ const version = versionIdOf(name);
247
+ if (version === null) throw new Error(`${name} is not listed with a version to regenerate`);
248
+ entry.replaces = version;
249
+ }
250
+ return write;
251
+ }
252
+ /**
253
+ * The name a key pair's public key gets beside its private key's:
254
+ * `JWT_PRIVATE_KEY` gives `JWT_PUBLIC_KEY`, any other name gains `_PUBLIC`.
255
+ */
256
+ function publicKeyName(privateName) {
257
+ const name = privateName.trim();
258
+ if (name === "") return "";
259
+ if (/PRIVATE/.test(name)) return name.replace(/PRIVATE/g, "PUBLIC");
260
+ if (/private/.test(name)) return name.replace(/private/g, "public");
261
+ return `${name}_PUBLIC`;
262
+ }
263
+ function wholeNumber(text) {
264
+ return text !== void 0 && /^\d{1,4}$/.test(text) ? Number(text) : null;
265
+ }
266
+ /** Web Crypto's keys need a secure context: HTTPS, or the machine's own address. */
267
+ function subtle() {
268
+ if (globalThis.crypto?.subtle === void 0) throw new Error("This page is not served over HTTPS, so the browser makes no keys here.");
269
+ return globalThis.crypto.subtle;
270
+ }
271
+ function webCrypto() {
272
+ if (globalThis.crypto?.getRandomValues === void 0) throw new Error("This runtime has no Web Crypto to draw random bytes from.");
273
+ return globalThis.crypto;
274
+ }
275
+ function toBase64(bytes) {
276
+ let binary = "";
277
+ for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte);
278
+ return btoa(binary);
279
+ }
280
+ //#endregion
281
+ export { CURVES, DEFAULT_RECIPE, ENCODINGS, MAX_BYTES, MAX_RECIPE_LENGTH, MIN_BYTES, PAIRS, RECIPE_SYNTAX, base62, describeRecipe, encodedLength, formatRecipe, generate, generateWrite, parseRecipe, publicKeyName, randomValue, recipeProblem, regenerateWrite };
@@ -10,19 +10,31 @@
10
10
  * These are immutable UUIDs, never names. Binding to `project/environment/key`
11
11
  * names would mean that renaming an environment or a secret key makes every
12
12
  * existing ciphertext permanently undecryptable.
13
+ *
14
+ * A personal config's values are bound to their owner too, `owner`, which
15
+ * only the vault's wrap of the data key carries (docs/design/forking.md,
16
+ * section 4): a forged owner column fails to open, rather than granting.
13
17
  */
14
18
  type SecretContext = {
15
19
  projectId: string;
16
20
  environmentId: string;
17
21
  secretId: string;
22
+ /** A personal config's owner, as `ownerKey` hashes them. */
23
+ owner?: string;
18
24
  };
19
25
  /**
20
26
  * Version prefix for the AAD encoding. If the encoding ever changes, this
21
27
  * changes with it, and old ciphertexts keep decrypting under the old rule.
22
28
  */
23
29
  declare const AAD_VERSION = "coffre.aad.v1";
24
- /** Throw unless every field is a lowercase UUID. */
30
+ /** Throw unless every field is a lowercase UUID, and an owner a lowercase SHA-256. */
25
31
  declare function checkContext(ctx: SecretContext): void;
32
+ /**
33
+ * A personal config's owner as its values' context binds them: the SHA-256
34
+ * of the member, `user:ada@acme.example`. Hashed, so it joins the AAD as
35
+ * unambiguously as a UUID: an email can hold the separator.
36
+ */
37
+ declare function ownerKey(member: string): string;
26
38
  /**
27
39
  * Encode a context into AAD bytes.
28
40
  *
@@ -123,4 +135,4 @@ declare class KekRegistry {
123
135
  unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer>;
124
136
  }
125
137
  //#endregion
126
- export { KekProvider as a, WrappedDek as c, checkContext as d, encodeAad as f, KekCancelledError as i, AAD_VERSION as l, DEK_BYTES as n, KekUnavailableError as o, KekBadClaimError as r, KeyOperation as s, KekRegistry as t, SecretContext as u };
138
+ export { KekProvider as a, WrappedDek as c, checkContext as d, encodeAad as f, KekCancelledError as i, AAD_VERSION as l, DEK_BYTES as n, KekUnavailableError as o, ownerKey as p, KekBadClaimError as r, KeyOperation as s, KekRegistry as t, SecretContext as u };
package/dist/schemas.d.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { z } from "zod";
2
2
  //#region src/schemas.d.ts
3
3
  export declare const slug: z.ZodString;
4
+ /**
5
+ * Your personal config of an environment, in a path: `market/dev@me`
6
+ * (docs/design/forking.md). A read falls back to `market/dev` when you have
7
+ * none; a write never does.
8
+ */
9
+ export declare const PERSONAL_SUFFIX = "@me";
10
+ /** `dev@me` as the environment it is a personal config of, `dev`; null for any other slug. */
11
+ export declare function personalOf(environment: string): string | null;
12
+ /** An environment as a path names it: a slug, or `<slug>@me` for your personal config of it. */
13
+ export declare const environmentPath: z.ZodString;
4
14
  /** An environment's slug: any slug but the name of one of its project's pages. */
5
15
  export declare const environmentSlug: z.ZodString;
6
16
  /**
package/dist/schemas.js CHANGED
@@ -1,8 +1,20 @@
1
- import { r as INSTANCE_ROLE_NAMES } from "./access-DvwZzqRR.js";
1
+ import { i as INSTANCE_ROLE_NAMES } from "./access-B1wpMzEB.js";
2
2
  import { PROJECT_PAGES } from "./pages.js";
3
3
  import { z } from "zod";
4
4
  //#region src/schemas.ts
5
5
  const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
6
+ /**
7
+ * Your personal config of an environment, in a path: `market/dev@me`
8
+ * (docs/design/forking.md). A read falls back to `market/dev` when you have
9
+ * none; a write never does.
10
+ */
11
+ const PERSONAL_SUFFIX = "@me";
12
+ /** `dev@me` as the environment it is a personal config of, `dev`; null for any other slug. */
13
+ function personalOf(environment) {
14
+ return environment.endsWith("@me") ? environment.slice(0, -3) : null;
15
+ }
16
+ /** An environment as a path names it: a slug, or `<slug>@me` for your personal config of it. */
17
+ const environmentPath = z.string().refine((value) => slug.safeParse(personalOf(value) ?? value).success, "not a valid environment");
6
18
  /** An environment's slug: any slug but the name of one of its project's pages. */
7
19
  const environmentSlug = slug.refine((value) => !PROJECT_PAGES.includes(value), { error: (issue) => `"${String(issue.input)}" is taken by a project's page, /projects/<project>/${String(issue.input)}: an environment cannot have it` });
8
20
  /**
@@ -49,4 +61,4 @@ const scopeInput = z.object({
49
61
  const grantId = z.string().uuid();
50
62
  const emailAddress = z.string().email().max(320);
51
63
  //#endregion
52
- export { TOMBSTONE, displayName, emailAddress, environmentSlug, folderName, grantId, instanceRole, isTombstone, principalId, principalType, scopeFilter, scopeInput, secretKey, shownMember, slug, tombstoneOf };
64
+ export { PERSONAL_SUFFIX, TOMBSTONE, displayName, emailAddress, environmentPath, environmentSlug, folderName, grantId, instanceRole, isTombstone, personalOf, principalId, principalType, scopeFilter, scopeInput, secretKey, shownMember, slug, tombstoneOf };
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  //#region src/context.ts
2
3
  /**
3
4
  * Version prefix for the AAD encoding. If the encoding ever changes, this
@@ -8,11 +9,21 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
8
9
  function assertUuid(value, field) {
9
10
  if (typeof value !== "string" || !UUID_RE.test(value)) throw new Error(`context.${field} must be a lowercase UUID, got: ${String(value)}`);
10
11
  }
11
- /** Throw unless every field is a lowercase UUID. */
12
+ /** Throw unless every field is a lowercase UUID, and an owner a lowercase SHA-256. */
12
13
  function checkContext(ctx) {
13
14
  assertUuid(ctx.projectId, "projectId");
14
15
  assertUuid(ctx.environmentId, "environmentId");
15
16
  assertUuid(ctx.secretId, "secretId");
17
+ if (ctx.owner !== void 0 && (typeof ctx.owner !== "string" || !OWNER_RE.test(ctx.owner))) throw new Error(`context.owner must be a lowercase SHA-256, got: ${String(ctx.owner)}`);
18
+ }
19
+ const OWNER_RE = /^[0-9a-f]{64}$/;
20
+ /**
21
+ * A personal config's owner as its values' context binds them: the SHA-256
22
+ * of the member, `user:ada@acme.example`. Hashed, so it joins the AAD as
23
+ * unambiguously as a UUID: an email can hold the separator.
24
+ */
25
+ function ownerKey(member) {
26
+ return createHash("sha256").update(member, "utf8").digest("hex");
16
27
  }
17
28
  /**
18
29
  * Encode a context into AAD bytes.
@@ -23,7 +34,7 @@ function checkContext(ctx) {
23
34
  */
24
35
  function encodeAad(ctx) {
25
36
  checkContext(ctx);
26
- return Buffer.from(`${AAD_VERSION}|${ctx.projectId}|${ctx.environmentId}|${ctx.secretId}`, "utf8");
37
+ return Buffer.from(`${AAD_VERSION}|${ctx.projectId}|${ctx.environmentId}|${ctx.secretId}${ctx.owner === void 0 ? "" : `|owner:${ctx.owner}`}`, "utf8");
27
38
  }
28
39
  //#endregion
29
40
  //#region src/kek/types.ts
@@ -54,4 +65,4 @@ var KekBadClaimError = class extends Error {
54
65
  name = "KekBadClaimError";
55
66
  };
56
67
  //#endregion
57
- export { AAD_VERSION as a, KekUnavailableError as i, KekBadClaimError as n, checkContext as o, KekCancelledError as r, encodeAad as s, DEK_BYTES as t };
68
+ export { AAD_VERSION as a, ownerKey as c, KekUnavailableError as i, KekBadClaimError as n, checkContext as o, KekCancelledError as r, encodeAad as s, DEK_BYTES as t };
package/dist/vault.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as Role, b as ServiceSetting, c as InstanceRole, i as GrantPlace, t as CredentialScope, v as Scope } from "./access-BpcBLDW9.js";
1
+ import { S as ServiceSetting, a as GrantPlace, b as Scope, l as InstanceRole, t as CredentialScope, y as Role } from "./access-CONhKG2P.js";
2
2
  //#region src/access-fault.d.ts
3
3
  /**
4
4
  * Why the vault's members and grants do not follow from its log, as facts
@@ -261,6 +261,12 @@ export type RefusalCode =
261
261
  'deleted' |
262
262
  /** The reference read through was ended: broken, or replaced by a value. */
263
263
  'ended' |
264
+ /**
265
+ * A personal config's value, which only its owner opens or writes, and
266
+ * only while they read the environment it is a config of: no grant or
267
+ * role reaches it, an Owner's included. Nor does any reference.
268
+ */
269
+ 'personal' |
264
270
  /** Not allowed to manage access, members or the log. */
265
271
  'not_allowed' |
266
272
  /**
@@ -358,11 +364,14 @@ export type UnwrapInput = Correlation & Through & {
358
364
  /**
359
365
  * `via` reads a source's current version through a reference: the vault
360
366
  * checks the reader's grants on the holder's environment, as its
361
- * `reference.create` entry names it, not on the source's.
367
+ * `reference.create` entry names it, not on the source's. `through`, an
368
+ * environment's id, is the fork an inherited value is read in: the vault
369
+ * logs the read there too, and decides on the value's own environment.
362
370
  */
363
371
  items: {
364
372
  secretVersionId: string;
365
373
  via?: Via;
374
+ through?: string;
366
375
  }[];
367
376
  };
368
377
  /** A reference, as a read names it: its id, and the seq of the vault's `reference.create` entry. */
@@ -4,10 +4,11 @@
4
4
  * their platform signs for them (docs/design/oidc.md).
5
5
  *
6
6
  * A binding names a profile, an issuer, and claims a token must carry, each
7
- * a top-level string matched exactly. The event that started the run may
8
- * be any of several: a list. The profile sets the minimum: the server
9
- * refuses a binding that lacks a claim its profile requires, from the UI,
10
- * the CLI or the API alike. Say a service is bound to `ci.yml` of
7
+ * a string matched exactly, at the top of the token or nested in it
8
+ * (`claimAt`). The event that started the run may be any of several: a
9
+ * list. The profile sets the minimum: the server refuses a binding that
10
+ * lacks a claim its profile requires, from the UI, the CLI or the API
11
+ * alike. Say a service is bound to `ci.yml` of
11
12
  * `acme/api`, pushed or dispatched on `main`, or run for a pull request
12
13
  * into `main`:
13
14
  *
@@ -72,7 +73,12 @@ export declare const MAX_CLAIM_BYTES = 256;
72
73
  /** A claim a binding names: one value, or, for what started the run, any of several. */
73
74
  export type ClaimValue = string | string[];
74
75
  export type BindingClaims = Record<string, ClaimValue>;
75
- /** What makes a binding, checked: its profile, its issuer as a URL, and its claims, sorted. */
76
+ /**
77
+ * What makes a binding, checked: its profile, its issuer, and its claims,
78
+ * sorted. The issuer is a URL coffre may fetch, unless the binding carries
79
+ * its keys (`uploaded`): then coffre fetches nothing, and it is any string,
80
+ * matched exactly, such as a private Kubernetes cluster's.
81
+ */
76
82
  export type BindingPolicy = {
77
83
  profile: WorkloadProfile;
78
84
  issuer: string;
@@ -84,9 +90,13 @@ export declare class BindingInvalid extends Error {
84
90
  }
85
91
  /** The issuer a profile means when none is given: github.com's or gitlab.com's. A GitHub Enterprise Server or a self-managed GitLab names its own. */
86
92
  export declare function defaultIssuer(profile: WorkloadProfile): string | null;
93
+ /** The longest issuer a binding with uploaded keys names, in bytes. */
94
+ export declare const MAX_ISSUER_BYTES = 400;
87
95
  /**
88
96
  * Check a binding against its profile, and return it as stored: the
89
- * issuer as given, the claims sorted by name. Throws `BindingInvalid`.
97
+ * issuer as given, the claims sorted by name. With `uploaded`, the binding
98
+ * carries its issuer's keys, and its issuer need not be a URL coffre can
99
+ * fetch. Throws `BindingInvalid`.
90
100
  */
91
101
  export declare function checkBinding(input: {
92
102
  profile: string;
@@ -94,13 +104,29 @@ export declare function checkBinding(input: {
94
104
  claims: Record<string, unknown>;
95
105
  }, options?: {
96
106
  allowLoopback?: boolean;
107
+ uploaded?: boolean;
97
108
  }): BindingPolicy;
98
109
  /** A claim's values, one or several. */
99
110
  export declare function claimValues(value: ClaimValue | undefined): string[];
111
+ /**
112
+ * A claim of a token, by the name a binding gives it: a top-level claim, or
113
+ * one nested in objects, its path joined by dots. A key may hold dots of
114
+ * its own, as Kubernetes' `kubernetes.io` does, so a name is read every way
115
+ * its dots can split it, and names a claim only when exactly one of those
116
+ * readings reaches a value; none, or several, and it names nothing. In
117
+ *
118
+ * { "sub": "system:serviceaccount:ci:deploy",
119
+ * "kubernetes.io": { "namespace": "ci", "serviceaccount": { "name": "deploy", "uid": "4f1c…" } } }
120
+ *
121
+ * `kubernetes.io.serviceaccount.uid` is `4f1c…`, `kubernetes.io` an object,
122
+ * and `kubernetes.io.pod` nothing.
123
+ */
124
+ export declare function claimAt(claims: Record<string, unknown>, name: string): unknown;
100
125
  /**
101
126
  * The claims of a verified token that differ from what a binding expects,
102
127
  * by name: none when it matches. A claim matches when the token carries its
103
- * value, or one of its values. On GitHub's profiles, two readings:
128
+ * value, or one of its values, where `claimAt` reads it. On GitHub's
129
+ * profiles, two readings:
104
130
  *
105
131
  * - A `pull_request` run, at `refs/pull/<n>/merge`, counts as a run at the
106
132
  * branch it merges into: its `ref`, and the ref its `workflow_ref` ends