@samebase/alchemy-convex 0.18.0 → 0.19.1
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/README.md +96 -69
- package/lib/{HubClient-BZKweNfD.js → HubClient-NUvPkolM.js} +53 -10
- package/lib/HubClient-NUvPkolM.js.map +1 -0
- package/lib/cli.js +2 -2
- package/lib/cli.js.map +1 -1
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/lib/sealedSecret-Bf8AeIp_.js +86 -0
- package/lib/sealedSecret-Bf8AeIp_.js.map +1 -0
- package/lib/sealedSecret.d.ts +37 -0
- package/lib/sealedSecret.d.ts.map +1 -0
- package/lib/sealedSecret.js +1 -0
- package/package.json +20 -19
- package/src/ManagementApi.ts +22 -1
- package/src/Project.ts +1 -1
- package/src/Push.ts +11 -7
- package/src/RunRecord.ts +1 -0
- package/src/StackComment.ts +2 -1
- package/src/State.ts +54 -1
- package/src/store/component/_generated/api.ts +2 -0
- package/src/store/component/_generated/component.ts +85 -1
- package/src/store/component/keys.ts +73 -0
- package/src/store/component/reader.ts +1 -0
- package/src/store/component/runRecord.ts +1 -0
- package/src/store/component/schema.ts +7 -0
- package/src/store/component/state.ts +86 -4
- package/src/store/convex/_generated/api.d.ts +2 -0
- package/src/store/convex/reader.ts +1 -0
- package/src/store/convex/sealing.ts +102 -0
- package/src/store/convex/state.ts +248 -22
- package/src/store/convex/version.ts +3 -1
- package/src/store/sealedSecret.ts +129 -0
- package/lib/HubClient-BZKweNfD.js.map +0 -1
|
@@ -8,14 +8,65 @@
|
|
|
8
8
|
// These names and arguments are the wire contract of STORE_VERSION. Samebase
|
|
9
9
|
// reads `stacks`, `getOutput`, and `readStack` by name: a renamed function is
|
|
10
10
|
// a version bump of the store and of Samebase.
|
|
11
|
+
//
|
|
12
|
+
// The rows hold their secrets sealed (see sealing.ts): `set` and `setOutput`
|
|
13
|
+
// are actions that seal and then write, and `get`, `replaced`, and `getOutput`
|
|
14
|
+
// open them for the run. `readStack` gives the rows as they are stored.
|
|
15
|
+
import type { FunctionReturnType } from "convex/server";
|
|
11
16
|
import { v } from "convex/values";
|
|
12
17
|
import { runRecord } from "../component/runRecord.ts";
|
|
13
18
|
import { stackReply } from "../component/state.ts";
|
|
14
|
-
import { components } from "./_generated/api.js";
|
|
15
|
-
import {
|
|
19
|
+
import { components, internal } from "./_generated/api.js";
|
|
20
|
+
import {
|
|
21
|
+
type ActionCtx,
|
|
22
|
+
internalAction,
|
|
23
|
+
internalMutation,
|
|
24
|
+
internalQuery,
|
|
25
|
+
type MutationCtx,
|
|
26
|
+
} from "./_generated/server.js";
|
|
27
|
+
import {
|
|
28
|
+
hasSecret,
|
|
29
|
+
openText,
|
|
30
|
+
outputAad,
|
|
31
|
+
requireKey,
|
|
32
|
+
resealText,
|
|
33
|
+
resourceAad,
|
|
34
|
+
sealText,
|
|
35
|
+
} from "./sealing.ts";
|
|
16
36
|
import { STORE_VERSION } from "./version.ts";
|
|
17
37
|
|
|
18
38
|
const state = components.alchemyState.state;
|
|
39
|
+
const keys = components.alchemyState.keys;
|
|
40
|
+
|
|
41
|
+
const NO_KEY = "The store has no key yet.";
|
|
42
|
+
|
|
43
|
+
const currentKey = async (ctx: ActionCtx) => {
|
|
44
|
+
const { current } = await ctx.runQuery(keys.sealing, {});
|
|
45
|
+
if (current === null) throw new Error(NO_KEY);
|
|
46
|
+
return current;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Seals a text and writes it. A write that sealed under a key that stopped being the current key
|
|
51
|
+
* does not commit, so it cannot land after the reseal of a rotation: it seals again.
|
|
52
|
+
*/
|
|
53
|
+
const sealedWrite = async (
|
|
54
|
+
ctx: ActionCtx,
|
|
55
|
+
text: string,
|
|
56
|
+
aad: string,
|
|
57
|
+
write: (value: string, keyId: string | null) => Promise<boolean>,
|
|
58
|
+
): Promise<null> => {
|
|
59
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
60
|
+
const keyId = hasSecret(text) ? await currentKey(ctx) : null;
|
|
61
|
+
const value = keyId === null ? text : await sealText(text, aad, keyId);
|
|
62
|
+
if (await write(value, keyId)) return null;
|
|
63
|
+
}
|
|
64
|
+
throw new Error("The current key of the store changed during three writes in a row.");
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** True when a value sealed under `keyId` must not commit. A text with no secret has no key. */
|
|
68
|
+
const isStale = async (ctx: MutationCtx, keyId: string | null) =>
|
|
69
|
+
keyId !== null && (await ctx.runQuery(keys.sealing, {})).current !== keyId;
|
|
19
70
|
|
|
20
71
|
/** The wire version of this store, compared with the client's STORE_VERSION. */
|
|
21
72
|
export const version = internalQuery({
|
|
@@ -47,34 +98,59 @@ export const list = internalQuery({
|
|
|
47
98
|
export const get = internalQuery({
|
|
48
99
|
args: { stack: v.string(), stage: v.string(), fqn: v.string() },
|
|
49
100
|
returns: v.union(v.string(), v.null()),
|
|
50
|
-
handler: (ctx, args) =>
|
|
51
|
-
|
|
101
|
+
handler: async (ctx, args) => {
|
|
102
|
+
const row = { stack: args.stack, stage: args.stage, fqn: args.fqn };
|
|
103
|
+
const value = await ctx.runQuery(state.get, row);
|
|
104
|
+
return value === null ? null : openText(value, resourceAad(row));
|
|
105
|
+
},
|
|
52
106
|
});
|
|
53
107
|
|
|
54
108
|
/** Resources whose state has `status: "replaced"`. */
|
|
55
109
|
export const replaced = internalQuery({
|
|
56
110
|
args: { stack: v.string(), stage: v.string() },
|
|
57
111
|
returns: v.array(v.string()),
|
|
58
|
-
handler: (ctx, args) =>
|
|
112
|
+
handler: async (ctx, args) => {
|
|
113
|
+
const rows = await ctx.runQuery(state.replaced, { stack: args.stack, stage: args.stage });
|
|
114
|
+
return Promise.all(
|
|
115
|
+
rows.map((row) =>
|
|
116
|
+
openText(row.value, resourceAad({ stack: args.stack, stage: args.stage, fqn: row.fqn })),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
},
|
|
59
120
|
});
|
|
60
121
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
122
|
+
const setArgs = {
|
|
123
|
+
stack: v.string(),
|
|
124
|
+
stage: v.string(),
|
|
125
|
+
fqn: v.string(),
|
|
126
|
+
value: v.string(),
|
|
127
|
+
run: v.optional(runRecord),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const set = internalAction({
|
|
131
|
+
args: setArgs,
|
|
69
132
|
returns: v.null(),
|
|
70
|
-
handler: (ctx, args) =>
|
|
71
|
-
ctx.
|
|
133
|
+
handler: (ctx, args): Promise<null> =>
|
|
134
|
+
sealedWrite(ctx, args.value, resourceAad(args), (value, keyId) =>
|
|
135
|
+
ctx.runMutation(internal.state.writeResource, { ...args, value, keyId }),
|
|
136
|
+
),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** The write of `set`. It answers false when `keyId` is no longer the current key. */
|
|
140
|
+
export const writeResource = internalMutation({
|
|
141
|
+
args: { ...setArgs, keyId: v.union(v.string(), v.null()) },
|
|
142
|
+
returns: v.boolean(),
|
|
143
|
+
handler: async (ctx, args) => {
|
|
144
|
+
if (await isStale(ctx, args.keyId)) return false;
|
|
145
|
+
await ctx.runMutation(state.set, {
|
|
72
146
|
stack: args.stack,
|
|
73
147
|
stage: args.stage,
|
|
74
148
|
fqn: args.fqn,
|
|
75
149
|
value: args.value,
|
|
76
150
|
run: args.run ?? null,
|
|
77
|
-
})
|
|
151
|
+
});
|
|
152
|
+
return true;
|
|
153
|
+
},
|
|
78
154
|
});
|
|
79
155
|
|
|
80
156
|
export const remove = internalMutation({
|
|
@@ -100,19 +176,43 @@ export const removeStack = internalMutation({
|
|
|
100
176
|
export const getOutput = internalQuery({
|
|
101
177
|
args: { stack: v.string(), stage: v.string() },
|
|
102
178
|
returns: v.union(v.string(), v.null()),
|
|
103
|
-
handler: (ctx, args) =>
|
|
179
|
+
handler: async (ctx, args) => {
|
|
180
|
+
const row = { stack: args.stack, stage: args.stage };
|
|
181
|
+
const value = await ctx.runQuery(state.getOutput, row);
|
|
182
|
+
return value === null ? null : openText(value, outputAad(row));
|
|
183
|
+
},
|
|
104
184
|
});
|
|
105
185
|
|
|
106
|
-
|
|
107
|
-
|
|
186
|
+
const setOutputArgs = {
|
|
187
|
+
stack: v.string(),
|
|
188
|
+
stage: v.string(),
|
|
189
|
+
value: v.string(),
|
|
190
|
+
run: v.optional(runRecord),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const setOutput = internalAction({
|
|
194
|
+
args: setOutputArgs,
|
|
108
195
|
returns: v.null(),
|
|
109
|
-
handler: (ctx, args) =>
|
|
110
|
-
ctx.
|
|
196
|
+
handler: (ctx, args): Promise<null> =>
|
|
197
|
+
sealedWrite(ctx, args.value, outputAad(args), (value, keyId) =>
|
|
198
|
+
ctx.runMutation(internal.state.writeOutput, { ...args, value, keyId }),
|
|
199
|
+
),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/** The write of `setOutput`. It answers false when `keyId` is no longer the current key. */
|
|
203
|
+
export const writeOutput = internalMutation({
|
|
204
|
+
args: { ...setOutputArgs, keyId: v.union(v.string(), v.null()) },
|
|
205
|
+
returns: v.boolean(),
|
|
206
|
+
handler: async (ctx, args) => {
|
|
207
|
+
if (await isStale(ctx, args.keyId)) return false;
|
|
208
|
+
await ctx.runMutation(state.setOutput, {
|
|
111
209
|
stack: args.stack,
|
|
112
210
|
stage: args.stage,
|
|
113
211
|
value: args.value,
|
|
114
212
|
run: args.run ?? null,
|
|
115
|
-
})
|
|
213
|
+
});
|
|
214
|
+
return true;
|
|
215
|
+
},
|
|
116
216
|
});
|
|
117
217
|
|
|
118
218
|
/** Every stage of a stack with its resources, output, and current record, in one call. Since store version 2. */
|
|
@@ -121,3 +221,129 @@ export const readStack = internalQuery({
|
|
|
121
221
|
returns: stackReply,
|
|
122
222
|
handler: (ctx, args) => ctx.runQuery(state.readStack, { stack: args.stack }),
|
|
123
223
|
});
|
|
224
|
+
|
|
225
|
+
/** The current key, and true when every secret is sealed under it. A run reads this each time. */
|
|
226
|
+
export const sealing = internalQuery({
|
|
227
|
+
args: {},
|
|
228
|
+
returns: v.object({
|
|
229
|
+
current: v.union(v.string(), v.null()),
|
|
230
|
+
generation: v.number(),
|
|
231
|
+
sealed: v.boolean(),
|
|
232
|
+
}),
|
|
233
|
+
handler: (ctx) => ctx.runQuery(keys.sealing, {}),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Makes `keyId` the current key of a store with no key, and answers the current key. Two first runs
|
|
238
|
+
* can both call it: the first one wins, and the variable of the other stays unused.
|
|
239
|
+
*/
|
|
240
|
+
export const chooseKey = internalMutation({
|
|
241
|
+
args: { keyId: v.string() },
|
|
242
|
+
returns: v.string(),
|
|
243
|
+
handler: (ctx, args) => {
|
|
244
|
+
requireKey(args.keyId);
|
|
245
|
+
return ctx.runMutation(keys.choose, args);
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/** Makes `keyId`, a key that the deployment holds, the current key. See `reseal`. */
|
|
250
|
+
export const rotateKey = internalMutation({
|
|
251
|
+
args: { keyId: v.string() },
|
|
252
|
+
returns: v.null(),
|
|
253
|
+
handler: (ctx, args) => {
|
|
254
|
+
requireKey(args.keyId);
|
|
255
|
+
return ctx.runMutation(keys.rotate, args);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Seals every secret of the store under the current key, one row at a time, through bounded pages.
|
|
261
|
+
* A run calls it until one finishes, so a run that stops only leaves work for the next one, and the
|
|
262
|
+
* rows that it sealed stay sealed. A row that fails, because a key is gone or a write limit was hit,
|
|
263
|
+
* is named in `failed`, and the others go on. A row that changed during the sweep is left: the write
|
|
264
|
+
* that changed it sealed it. A sweep
|
|
265
|
+
* stops when the current key changes: the sweep of the new key does the work, and this one must not
|
|
266
|
+
* put the older key back.
|
|
267
|
+
*/
|
|
268
|
+
export const reseal = internalAction({
|
|
269
|
+
args: {},
|
|
270
|
+
returns: v.object({
|
|
271
|
+
resealed: v.number(),
|
|
272
|
+
skippedChanged: v.number(),
|
|
273
|
+
failed: v.array(v.string()),
|
|
274
|
+
}),
|
|
275
|
+
handler: async (ctx) => {
|
|
276
|
+
const { current: keyId, generation } = await ctx.runQuery(keys.sealing, {});
|
|
277
|
+
if (keyId === null) throw new Error(NO_KEY);
|
|
278
|
+
let resealed = 0;
|
|
279
|
+
let skippedChanged = 0;
|
|
280
|
+
const failed: string[] = [];
|
|
281
|
+
/** Reseals one row, and answers true when the sweep must stop. */
|
|
282
|
+
const sweep = async (
|
|
283
|
+
name: string,
|
|
284
|
+
aad: string,
|
|
285
|
+
expected: string,
|
|
286
|
+
replace: (value: string) => Promise<"replaced" | "changed" | "stale">,
|
|
287
|
+
) => {
|
|
288
|
+
try {
|
|
289
|
+
const value = await resealText(expected, aad, keyId);
|
|
290
|
+
if (value === expected) return false;
|
|
291
|
+
const result = await replace(value);
|
|
292
|
+
if (result === "replaced") resealed += 1;
|
|
293
|
+
if (result === "changed") skippedChanged += 1;
|
|
294
|
+
return result === "stale";
|
|
295
|
+
} catch {
|
|
296
|
+
// The row name is enough: an error can name a key id, and it adds nothing to find the row.
|
|
297
|
+
failed.push(name);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const done = () => ({ resealed, skippedChanged, failed });
|
|
302
|
+
|
|
303
|
+
let cursor: string | null = null;
|
|
304
|
+
for (;;) {
|
|
305
|
+
const { page, continueCursor, isDone }: FunctionReturnType<typeof state.resourcePage> =
|
|
306
|
+
await ctx.runQuery(state.resourcePage, { cursor });
|
|
307
|
+
for (const { stack, stage, fqn, value: expected } of page) {
|
|
308
|
+
const stale = await sweep(
|
|
309
|
+
`${stack}/${stage}/${fqn}`,
|
|
310
|
+
resourceAad({ stack, stage, fqn }),
|
|
311
|
+
expected,
|
|
312
|
+
(value) =>
|
|
313
|
+
ctx.runMutation(state.replaceResource, {
|
|
314
|
+
stack,
|
|
315
|
+
stage,
|
|
316
|
+
fqn,
|
|
317
|
+
expected,
|
|
318
|
+
value,
|
|
319
|
+
generation,
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
if (stale) return done();
|
|
323
|
+
}
|
|
324
|
+
if (isDone) break;
|
|
325
|
+
cursor = continueCursor;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
cursor = null;
|
|
329
|
+
for (;;) {
|
|
330
|
+
const { page, continueCursor, isDone }: FunctionReturnType<typeof state.outputPage> =
|
|
331
|
+
await ctx.runQuery(state.outputPage, { cursor });
|
|
332
|
+
for (const { stack, stage, value: expected } of page) {
|
|
333
|
+
const stale = await sweep(
|
|
334
|
+
`${stack}/${stage} output`,
|
|
335
|
+
outputAad({ stack, stage }),
|
|
336
|
+
expected,
|
|
337
|
+
(value) =>
|
|
338
|
+
ctx.runMutation(state.replaceOutput, { stack, stage, expected, value, generation }),
|
|
339
|
+
);
|
|
340
|
+
if (stale) return done();
|
|
341
|
+
}
|
|
342
|
+
if (isDone) break;
|
|
343
|
+
cursor = continueCursor;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (failed.length === 0) await ctx.runMutation(keys.markSealed, { generation });
|
|
347
|
+
return done();
|
|
348
|
+
},
|
|
349
|
+
});
|
|
@@ -10,4 +10,6 @@
|
|
|
10
10
|
// 2: `readStack` (0.7.0).
|
|
11
11
|
// 3: authenticated subscriptions and current stage run records (0.9.0).
|
|
12
12
|
// 4: the store is a component (same functions).
|
|
13
|
-
|
|
13
|
+
// 5: rows hold their secrets sealed: `set` and `setOutput` are actions, with `sealing`, `chooseKey`,
|
|
14
|
+
// `rotateKey`, and `reseal` (0.19.0).
|
|
15
|
+
export const STORE_VERSION = 5;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// AES-256-GCM sealing under a key ring, with Web Crypto only, so that it runs in Convex and in Node.
|
|
2
|
+
//
|
|
3
|
+
// A key ring is the JSON text `{"current":"<keyId>","keys":{"<keyId>":"<base64 of 32 bytes>"}}`,
|
|
4
|
+
// which a deployment keeps in one environment variable. `sealSecret` seals with the `current` key.
|
|
5
|
+
// `openSecret` opens with the key that the sealed object names, so an old key stays in the ring
|
|
6
|
+
// until a reseal moves its values to the current key.
|
|
7
|
+
//
|
|
8
|
+
// Seal only where randomness is real: in a Convex action, or outside Convex. In a Convex query or
|
|
9
|
+
// mutation `crypto.getRandomValues` is a seeded deterministic generator, and a repeated IV under one
|
|
10
|
+
// key breaks AES-GCM. Opening draws no randomness, so a query can open.
|
|
11
|
+
|
|
12
|
+
/** A value sealed with AES-256-GCM under the key `keyId` of a key ring. */
|
|
13
|
+
export interface SealedSecret {
|
|
14
|
+
readonly keyId: string;
|
|
15
|
+
/** 12 random bytes, base64url. */
|
|
16
|
+
readonly iv: string;
|
|
17
|
+
/** The AES-GCM output with its 16-byte tag, base64url. */
|
|
18
|
+
readonly ciphertext: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KeyRing {
|
|
22
|
+
readonly current: string;
|
|
23
|
+
readonly keys: ReadonlyMap<string, Uint8Array<ArrayBuffer>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const toBase64 = (bytes: Uint8Array) =>
|
|
27
|
+
btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""));
|
|
28
|
+
|
|
29
|
+
const toBase64Url = (bytes: Uint8Array) =>
|
|
30
|
+
toBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
31
|
+
|
|
32
|
+
/** The bytes of base64 or base64url text. */
|
|
33
|
+
const fromBase64 = (text: string) => {
|
|
34
|
+
const standard = text.replaceAll("-", "+").replaceAll("_", "/");
|
|
35
|
+
const padded = standard.padEnd(standard.length + ((4 - (standard.length % 4)) % 4), "=");
|
|
36
|
+
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** A key of 32 bytes in base64 is 43 characters and one `=`. */
|
|
40
|
+
const KEY_TEXT = /^[A-Za-z0-9+/]{43}=$/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The key ring in the text of the variable `name`. It fails closed when the text is missing or
|
|
44
|
+
* malformed, or when the current key id has no key. An error never quotes the text, which holds
|
|
45
|
+
* the keys.
|
|
46
|
+
*/
|
|
47
|
+
export const parseKeyRing = (name: string, text: string | undefined): KeyRing => {
|
|
48
|
+
let value: unknown;
|
|
49
|
+
try {
|
|
50
|
+
value = JSON.parse(text ?? "");
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error(`${name} is missing or is not JSON.`);
|
|
53
|
+
}
|
|
54
|
+
const malformed = new Error(
|
|
55
|
+
`${name} must be {"current":"<keyId>","keys":{"<keyId>":"<base64 of 32 bytes>"}}.`,
|
|
56
|
+
);
|
|
57
|
+
if (typeof value !== "object" || value === null) throw malformed;
|
|
58
|
+
if (!("current" in value) || !("keys" in value)) throw malformed;
|
|
59
|
+
const { current, keys: keyTexts } = value;
|
|
60
|
+
if (typeof current !== "string" || typeof keyTexts !== "object" || keyTexts === null) {
|
|
61
|
+
throw malformed;
|
|
62
|
+
}
|
|
63
|
+
const keys = new Map<string, Uint8Array<ArrayBuffer>>();
|
|
64
|
+
for (const [keyId, key] of Object.entries(keyTexts)) {
|
|
65
|
+
if (typeof key !== "string" || !KEY_TEXT.test(key)) throw malformed;
|
|
66
|
+
keys.set(keyId, fromBase64(key));
|
|
67
|
+
}
|
|
68
|
+
if (!keys.has(current)) throw new Error(`${name} has no key for its current key id.`);
|
|
69
|
+
return { current, keys };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** A random key id of 12 hexadecimal characters, which is also safe in a variable name. */
|
|
73
|
+
export const newKeyId = (): string =>
|
|
74
|
+
Array.from(crypto.getRandomValues(new Uint8Array(6)), (byte) =>
|
|
75
|
+
byte.toString(16).padStart(2, "0"),
|
|
76
|
+
).join("");
|
|
77
|
+
|
|
78
|
+
/** A random key of 32 bytes in base64. It needs real randomness, like `sealSecret`. */
|
|
79
|
+
export const newKey = (): string => toBase64(crypto.getRandomValues(new Uint8Array(32)));
|
|
80
|
+
|
|
81
|
+
/** The text of a new key ring with one random key. */
|
|
82
|
+
export const newKeyRing = (): string => {
|
|
83
|
+
const keyId = newKeyId();
|
|
84
|
+
return JSON.stringify({ current: keyId, keys: { [keyId]: newKey() } });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const importKey = (ring: KeyRing, keyId: string) => {
|
|
88
|
+
const bytes = ring.keys.get(keyId);
|
|
89
|
+
if (bytes === undefined) throw new Error(`The key ring has no key "${keyId}".`);
|
|
90
|
+
return crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Seals `plaintext` with the current key. `aad` names the owner, so the value opens only there. */
|
|
94
|
+
export const sealSecret = async (
|
|
95
|
+
ring: KeyRing,
|
|
96
|
+
args: { readonly plaintext: string; readonly aad: string },
|
|
97
|
+
): Promise<SealedSecret> => {
|
|
98
|
+
const key = await importKey(ring, ring.current);
|
|
99
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
100
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
101
|
+
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(args.aad) },
|
|
102
|
+
key,
|
|
103
|
+
new TextEncoder().encode(args.plaintext),
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
keyId: ring.current,
|
|
107
|
+
iv: toBase64Url(iv),
|
|
108
|
+
ciphertext: toBase64Url(new Uint8Array(ciphertext)),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Opens a sealed value with the key it names. A different `aad` or a changed value fails. */
|
|
113
|
+
export const openSecret = async (
|
|
114
|
+
ring: KeyRing,
|
|
115
|
+
args: { readonly sealed: SealedSecret; readonly aad: string },
|
|
116
|
+
): Promise<string> => {
|
|
117
|
+
const { sealed } = args;
|
|
118
|
+
const key = await importKey(ring, sealed.keyId);
|
|
119
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
120
|
+
{
|
|
121
|
+
name: "AES-GCM",
|
|
122
|
+
iv: fromBase64(sealed.iv),
|
|
123
|
+
additionalData: new TextEncoder().encode(args.aad),
|
|
124
|
+
},
|
|
125
|
+
key,
|
|
126
|
+
fromBase64(sealed.ciphertext),
|
|
127
|
+
);
|
|
128
|
+
return new TextDecoder().decode(plaintext);
|
|
129
|
+
};
|