@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/recipe.d.ts
ADDED
|
@@ -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 {
|
|
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 {
|
|
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. */
|
package/dist/workloads.d.ts
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
|
*
|
|
@@ -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
|
-
/**
|
|
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.
|
|
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
|
|
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
|