@maestro-js/crypt 1.0.0-alpha.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/README.md ADDED
@@ -0,0 +1,248 @@
1
+ # Maestro Crypt
2
+
3
+ ## Overview
4
+
5
+ The `@maestro-js/crypt` package provides AES-256-GCM encryption and decryption with automatic key rotation. Encrypted strings use a dot-delimited format (`keyId.iv.ciphertext.authTag`) that embeds a short key identifier, allowing the correct key to be selected during decryption even after key rotation.
6
+
7
+ ## Quick Setup
8
+
9
+ ```ts
10
+ import { Crypt } from '@maestro-js/crypt'
11
+ import crypto from 'node:crypto'
12
+
13
+ // Create a service with a 32-byte base64-encoded key
14
+ const service = Crypt.Provider.create({
15
+ key: process.env.ENCRYPTION_KEY!, // base64-encoded, 32 bytes decoded
16
+ previousKeys: [] // no rotated keys yet
17
+ })
18
+
19
+ // Register as the default provider
20
+ Crypt.Provider.register('default', service)
21
+
22
+ // Encrypt and decrypt
23
+ const encrypted = Crypt.encryptString('sensitive data')
24
+ const plaintext = Crypt.decryptString(encrypted) // 'sensitive data'
25
+ ```
26
+
27
+ Generate a valid key: `crypto.randomBytes(32).toString('base64')`
28
+
29
+ ## API Reference
30
+
31
+ ### `Crypt.Provider.create(config)`
32
+
33
+ Create a new Crypt service instance from a keyset.
34
+
35
+ ```ts
36
+ function create(config: {
37
+ key: string // current primary key, base64-encoded (32 bytes decoded)
38
+ previousKeys: string[] // previously-used keys for decryption, base64-encoded
39
+ }): Crypt.CryptService
40
+ ```
41
+
42
+ Each key is assigned a short ID (first 4 characters of its base64 SHA-256 hash). Keys must be exactly 32 bytes when decoded from base64.
43
+
44
+ ```ts
45
+ const service = Crypt.Provider.create({
46
+ key: 'aGVsbG93b3JsZGhlbGxvd29ybGRoZWxsb3dvcmxkMTI=',
47
+ previousKeys: []
48
+ })
49
+ ```
50
+
51
+ ### `Crypt.Provider.register(name, service)`
52
+
53
+ Register a service instance in the named registry.
54
+
55
+ ```ts
56
+ Crypt.Provider.register('default', service)
57
+ ```
58
+
59
+ ### `Crypt.provider(key)`
60
+
61
+ Resolve a named provider instance. The default facade (`Crypt.encryptString`, etc.) delegates to `Crypt.provider('default')`.
62
+
63
+ ```ts
64
+ const fieldCrypt = Crypt.provider('field-level')
65
+ const encrypted = fieldCrypt.encryptString('SSN: 123-45-6789')
66
+ ```
67
+
68
+ ### `encryptString(plaintext)`
69
+
70
+ Encrypt a plaintext string using AES-256-GCM with the current primary key. Generates a random 12-byte IV per call, so encrypting the same string twice produces different ciphertext.
71
+
72
+ ```ts
73
+ encryptString(plaintext: string): string
74
+ ```
75
+
76
+ Returns a dot-delimited string: `{keyId}.{iv}.{ciphertext}.{authTag}`.
77
+
78
+ ```ts
79
+ const token1 = Crypt.encryptString('user-session-abc')
80
+ const token2 = Crypt.encryptString('user-session-abc')
81
+ // token1 !== token2 (different IVs)
82
+ ```
83
+
84
+ ### `decryptString(encryptedText)`
85
+
86
+ Decrypt a dot-delimited cipher string. Parses the embedded key ID and tries all keys in the keyset with a matching ID. Throws if no key can decrypt the text.
87
+
88
+ ```ts
89
+ decryptString(encryptedText: string): string
90
+ ```
91
+
92
+ ```ts
93
+ const plaintext = Crypt.decryptString(encrypted) // 'user-session-abc'
94
+ ```
95
+
96
+ ### `getEncryptingKeyId(encryptedText)`
97
+
98
+ Extract the key ID from an encrypted string without decrypting it. Useful for checking whether data needs re-encryption after a key rotation.
99
+
100
+ ```ts
101
+ getEncryptingKeyId(encryptedText: string): string | undefined
102
+ ```
103
+
104
+ ```ts
105
+ const keyId = Crypt.getEncryptingKeyId(encrypted) // e.g. 'aB3d'
106
+ ```
107
+
108
+ ### `getCurrentKeyId()`
109
+
110
+ Return the ID of the current primary encryption key.
111
+
112
+ ```ts
113
+ getCurrentKeyId(): string
114
+ ```
115
+
116
+ ```ts
117
+ const currentId = Crypt.getCurrentKeyId() // e.g. 'aB3d'
118
+
119
+ // Check if data needs re-encryption
120
+ if (Crypt.getEncryptingKeyId(encrypted) !== Crypt.getCurrentKeyId()) {
121
+ const refreshed = Crypt.encryptString(Crypt.decryptString(encrypted))
122
+ }
123
+ ```
124
+
125
+ ## Key Rotation
126
+
127
+ Key rotation allows transitioning to a new encryption key without breaking decryption of existing data. Move the old key into `previousKeys` and set the new key as `key`.
128
+
129
+ ```ts
130
+ // Before rotation: key1 is the primary key
131
+ const service = Crypt.Provider.create({
132
+ key: key1,
133
+ previousKeys: []
134
+ })
135
+
136
+ // After rotation: key2 is primary, key1 still decrypts old data
137
+ const rotatedService = Crypt.Provider.create({
138
+ key: key2,
139
+ previousKeys: [key1]
140
+ })
141
+
142
+ // New encryptions use key2
143
+ const newEncrypted = rotatedService.encryptString('new data')
144
+
145
+ // Old data encrypted with key1 still decrypts
146
+ const oldPlaintext = rotatedService.decryptString(oldEncryptedWithKey1)
147
+ ```
148
+
149
+ ### Re-encrypting Existing Data
150
+
151
+ After rotation, identify and re-encrypt data that uses old keys:
152
+
153
+ ```ts
154
+ function reEncryptIfNeeded(encryptedValue: string): string {
155
+ if (Crypt.getEncryptingKeyId(encryptedValue) !== Crypt.getCurrentKeyId()) {
156
+ return Crypt.encryptString(Crypt.decryptString(encryptedValue))
157
+ }
158
+ return encryptedValue
159
+ }
160
+ ```
161
+
162
+ ## Common Patterns
163
+
164
+ ### Field-Level Encryption in Database Records
165
+
166
+ ```ts
167
+ // Encrypt before storing
168
+ const encryptedSsn = Crypt.encryptString('123-45-6789')
169
+ await Db.query('INSERT INTO users (name, ssn_encrypted) VALUES (?, ?)', ['Alice', encryptedSsn])
170
+
171
+ // Decrypt after reading
172
+ const [row] = await Db.query('SELECT ssn_encrypted FROM users WHERE id = ?', [userId])
173
+ const ssn = Crypt.decryptString(row.ssn_encrypted) // '123-45-6789'
174
+ ```
175
+
176
+ ### Multiple Named Providers
177
+
178
+ ```ts
179
+ // Separate keysets for different sensitivity levels
180
+ Crypt.Provider.register('default', Crypt.Provider.create({
181
+ key: process.env.GENERAL_ENCRYPTION_KEY!,
182
+ previousKeys: []
183
+ }))
184
+
185
+ Crypt.Provider.register('pii', Crypt.Provider.create({
186
+ key: process.env.PII_ENCRYPTION_KEY!,
187
+ previousKeys: [process.env.PII_OLD_KEY!]
188
+ }))
189
+
190
+ // Use the PII provider for sensitive fields
191
+ const piiCrypt = Crypt.provider('pii')
192
+ const encryptedSsn = piiCrypt.encryptString(ssn)
193
+ ```
194
+
195
+ ### Encrypted String Format
196
+
197
+ The dot-delimited format is: `{keyId}.{iv}.{ciphertext}.{authTag}`
198
+
199
+ - **keyId** -- first 4 chars of the key's base64 SHA-256 hash
200
+ - **iv** -- random 12-byte initialization vector, base64-encoded
201
+ - **ciphertext** -- AES-256-GCM encrypted data, base64-encoded
202
+ - **authTag** -- GCM authentication tag, base64-encoded
203
+
204
+ ## Testing
205
+
206
+ Use `Crypt.Provider.create` directly in tests with generated keys. No mock driver is needed since encryption is CPU-only with no external dependencies.
207
+
208
+ ```ts
209
+ import crypto from 'node:crypto'
210
+ import { Crypt } from '@maestro-js/crypt'
211
+ import { test } from 'beartest-js'
212
+ import { expect } from 'expect'
213
+
214
+ test('encrypt and decrypt a value', () => {
215
+ const key = crypto.randomBytes(32).toString('base64')
216
+ const service = Crypt.Provider.create({ key, previousKeys: [] })
217
+
218
+ const encrypted = service.encryptString('sensitive-token')
219
+ const decrypted = service.decryptString(encrypted)
220
+
221
+ expect(decrypted).toBe('sensitive-token')
222
+ })
223
+
224
+ test('key rotation decrypts old and new data', () => {
225
+ const key1 = crypto.randomBytes(32).toString('base64')
226
+ const key2 = crypto.randomBytes(32).toString('base64')
227
+
228
+ const oldService = Crypt.Provider.create({ key: key1, previousKeys: [] })
229
+ const oldEncrypted = oldService.encryptString('original-secret')
230
+
231
+ const newService = Crypt.Provider.create({ key: key2, previousKeys: [key1] })
232
+ const newEncrypted = newService.encryptString('new-secret')
233
+
234
+ expect(newService.decryptString(oldEncrypted)).toBe('original-secret')
235
+ expect(newService.decryptString(newEncrypted)).toBe('new-secret')
236
+ expect(newService.getEncryptingKeyId(newEncrypted)).not.toBe(
237
+ newService.getEncryptingKeyId(oldEncrypted)
238
+ )
239
+ })
240
+ ```
241
+
242
+ Run tests: `pnpm --filter @maestro-js/crypt test`
243
+
244
+ ## Cross-Package Integration
245
+
246
+ - **Depends on**: `@maestro-js/service-registry` for the Provider pattern (registry, deferred proxy resolution)
247
+ - **Architectural sibling**: `@maestro-js/hash` follows the same single-driver Provider pattern
248
+ - **Standalone**: No downstream maestro packages depend on Crypt directly; it is used at the application layer for field-level encryption
@@ -0,0 +1,108 @@
1
+ type KeysWithFallback = keyof Crypt.Provider.Keys extends never ? {
2
+ default: unknown;
3
+ } : Crypt.Provider.Keys;
4
+ /**
5
+ * Creates a new Crypt service instance from the given keyset.
6
+ *
7
+ * Each key is assigned a short id (first 4 chars of its SHA-256 hash, base64-encoded)
8
+ * which is embedded in encrypted output so the correct key can be identified during decryption.
9
+ *
10
+ * @param keysetInput - The primary key and any previous keys for decryption.
11
+ * @returns A CryptService with encrypt/decrypt operations bound to the provided keyset.
12
+ */
13
+ declare function create(keysetInput: Crypt.Provider.CryptServiceConfig): {
14
+ encryptString: (plaintext: string) => string;
15
+ decryptString: (encryptedText: string) => string;
16
+ getEncryptingKeyId: (encryptedText: string) => string | undefined;
17
+ getCurrentKeyId: () => string;
18
+ };
19
+ declare function provider(key: Crypt.Provider.Key): {
20
+ encryptString: (plaintext: string) => string;
21
+ decryptString: (encryptedText: string) => string;
22
+ getEncryptingKeyId: (encryptedText: string) => string | undefined;
23
+ getCurrentKeyId: () => string;
24
+ };
25
+ /**
26
+ * AES-256-GCM encryption and decryption with automatic key rotation.
27
+ *
28
+ * Encrypted strings use a dot-delimited format (`keyId.iv.ciphertext.authTag`)
29
+ * that embeds a short key identifier, allowing the correct key to be selected
30
+ * during decryption. New data is always encrypted with the current key, while
31
+ * older data can still be decrypted using any previously registered key.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * Crypt.Provider.register('default', Crypt.Provider.create({
36
+ * key: 'base64-encoded-32-byte-key',
37
+ * previousKeys: ['old-base64-key'],
38
+ * }))
39
+ *
40
+ * const encrypted = Crypt.encryptString('sensitive data')
41
+ * Crypt.decryptString(encrypted) // 'sensitive data'
42
+ * Crypt.getCurrentKeyId() // 'aB3d'
43
+ * Crypt.getEncryptingKeyId(encrypted) // 'aB3d'
44
+ * ```
45
+ */
46
+ declare namespace Crypt {
47
+ namespace Provider {
48
+ interface Keyset {
49
+ /** The current (primary) encryption key, base64-encoded. Must be 32 bytes decoded (for AES-256). */
50
+ key: string;
51
+ /** Previously-used keys that can still decrypt existing data, base64-encoded. */
52
+ previousKeys: string[];
53
+ }
54
+ interface CryptServiceConfig extends Keyset {
55
+ }
56
+ type Key = keyof KeysWithFallback;
57
+ interface Keys {
58
+ }
59
+ }
60
+ /**
61
+ * AES-256-GCM encryption service bound to a specific keyset.
62
+ *
63
+ * The service maintains an ordered list of keys:
64
+ *
65
+ * - **Current key** – used by {@link encryptString} for all new encryptions.
66
+ * - **Previous keys** – tried by {@link decryptString} when the embedded key
67
+ * id matches, enabling zero-downtime key rotation.
68
+ *
69
+ * Each key is identified by a short id (first 4 characters of its base64
70
+ * SHA-256 hash) embedded in every encrypted string.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * const encrypted = Crypt.encryptString('secret')
75
+ * Crypt.decryptString(encrypted) // 'secret'
76
+ * Crypt.getEncryptingKeyId(encrypted) === Crypt.getCurrentKeyId() // true
77
+ * ```
78
+ */
79
+ interface CryptService {
80
+ /** Encrypts a plaintext string using the current key and returns a dot-delimited cipher string */
81
+ encryptString: (plaintext: string) => string;
82
+ /** Decrypts a dot-delimited cipher string, trying all keys whose id matches the embedded key id */
83
+ decryptString: (encryptedText: string) => string;
84
+ /** Extracts the key id from an encrypted string without decrypting it */
85
+ getEncryptingKeyId: (encryptedText: string) => string | undefined;
86
+ /** Returns the id of the current encryption key */
87
+ getCurrentKeyId: () => string;
88
+ }
89
+ }
90
+ /**
91
+ * AES-256-GCM encryption with automatic key rotation.
92
+ * Call `Crypt.Provider.create()` with a keyset and register it before use.
93
+ */
94
+ declare const Crypt: {
95
+ provider: typeof provider;
96
+ encryptString: (plaintext: string) => string;
97
+ decryptString: (encryptedText: string) => string;
98
+ getEncryptingKeyId: (encryptedText: string) => string | undefined;
99
+ getCurrentKeyId: () => string;
100
+ Provider: {
101
+ /** Creates a new Crypt service instance from a keyset. */
102
+ create: typeof create;
103
+ /** Registers a Crypt service instance by name. Use `'default'` for the primary instance. */
104
+ register: (name: Crypt.Provider.Key, item: Crypt.CryptService) => void;
105
+ };
106
+ };
107
+
108
+ export { Crypt };
package/dist/index.js ADDED
@@ -0,0 +1,88 @@
1
+ // src/index.ts
2
+ import assert from "assert";
3
+ import crypto from "crypto";
4
+ import { ServiceRegistry } from "@maestro-js/service-registry";
5
+ function create(keysetInput) {
6
+ const keyset = [keysetInput.key, ...keysetInput.previousKeys].map((key) => ({
7
+ key,
8
+ id: crypto.createHash("sha256").update(key).digest("base64").substr(0, 4)
9
+ }));
10
+ for (const k of keyset) {
11
+ const decoded = Buffer.from(k.key, "base64");
12
+ assert.ok(decoded.length === 32, `Crypt key must be 32 bytes (got ${decoded.length})`);
13
+ }
14
+ assert.ok(keyset[0], "Keyset has not been set for Crypt");
15
+ const primaryKey = keyset[0];
16
+ function encrypt(key, iv, plaintext) {
17
+ const cipher = crypto.createCipheriv("aes-256-gcm", Buffer.from(key.key, "base64"), iv);
18
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]).toString("base64");
19
+ const tag = cipher.getAuthTag().toString("base64");
20
+ return [key.id, iv, encrypted, tag].join(".");
21
+ }
22
+ function encryptString(plaintext) {
23
+ const iv = crypto.randomBytes(12).toString("base64");
24
+ return encrypt(primaryKey, iv, plaintext);
25
+ }
26
+ function decryptString(encryptedText) {
27
+ const [key, iv, encrypted, tag] = encryptedText.split(".");
28
+ assert.ok(key, "no key identified for encryptedText");
29
+ assert.ok(iv, "no iv identified for encryptedText");
30
+ assert.ok(encrypted, "no body identified for encryptedText");
31
+ assert.ok(tag, "no tag identified for encryptedText");
32
+ const matchingKeys = keyset.filter((k) => k.id === key);
33
+ if (matchingKeys.length === 0) {
34
+ throw new Error(`No key found matching id "${key}"`);
35
+ }
36
+ let err;
37
+ for (let decipherKey of matchingKeys) {
38
+ try {
39
+ const decipher = crypto.createDecipheriv("aes-256-gcm", Buffer.from(decipherKey.key, "base64"), iv);
40
+ decipher.setAuthTag(Buffer.from(tag, "base64"));
41
+ let plaintext = decipher.update(encrypted, "base64", "utf8");
42
+ plaintext += decipher.final("utf8");
43
+ return plaintext;
44
+ } catch (e) {
45
+ err = e;
46
+ }
47
+ }
48
+ throw err;
49
+ }
50
+ function getEncryptingKeyId(encryptedText) {
51
+ const [key] = encryptedText.split(".");
52
+ return key;
53
+ }
54
+ function getCurrentKeyId() {
55
+ return primaryKey.id;
56
+ }
57
+ return {
58
+ encryptString,
59
+ decryptString,
60
+ getEncryptingKeyId,
61
+ getCurrentKeyId
62
+ };
63
+ }
64
+ var registry = ServiceRegistry.createRegistry(ServiceRegistry.proxyFunctionsForObject);
65
+ var Provider = {
66
+ /** Creates a new Crypt service instance from a keyset. */
67
+ create,
68
+ /** Registers a Crypt service instance by name. Use `'default'` for the primary instance. */
69
+ register: registry.register
70
+ };
71
+ function provider(key) {
72
+ const service = registry.resolve(key);
73
+ return {
74
+ encryptString: service.encryptString,
75
+ decryptString: service.decryptString,
76
+ getEncryptingKeyId: service.getEncryptingKeyId,
77
+ getCurrentKeyId: service.getCurrentKeyId
78
+ };
79
+ }
80
+ var facade = provider("default");
81
+ var Crypt = {
82
+ Provider,
83
+ ...facade,
84
+ provider
85
+ };
86
+ export {
87
+ Crypt
88
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@maestro-js/crypt",
3
+ "description": "AES-256-GCM encryption and decryption with automatic key rotation for the maestro-js framework. Use when working with @maestro-js/crypt, encrypting or decrypting strings, rotating encryption keys, checking key IDs on encrypted data, or setting up field-level encryption. Key capabilities include AES-256-GCM authenticated encryption, dot-delimited cipher format with embedded key IDs, zero-downtime key rotation via previousKeys, per-call random IV generation, Provider pattern with deferred resolution, and key identification without decryption.",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
+ }
10
+ },
11
+ "dependencies": {
12
+ "@maestro-js/service-registry": "1.0.0-alpha.0"
13
+ },
14
+ "devDependencies": {
15
+ "@types/node": "^22.9.0"
16
+ },
17
+ "version": "1.0.0-alpha.0",
18
+ "publishConfig": {
19
+ "access": "restricted"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "license": "UNLICENSED",
25
+ "engines": {
26
+ "node": ">=22.18.0"
27
+ },
28
+ "repository": "https://github.com/Marcato-Partners/maestro-js",
29
+ "scripts": {
30
+ "build": "tsup --config ../../tsup.config.ts",
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "beartest ./tests/**/*",
33
+ "format": "prettier --write src/ tests/",
34
+ "lint": "prettier --check src/ tests/"
35
+ }
36
+ }