@samebase/alchemy-convex 0.17.0 → 0.19.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,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
+ };