Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit bfd9642

Browse files
committed
feat(encryption): add encryption plugin to close feature gap
1 parent a70a1cb commit bfd9642

14 files changed

Lines changed: 971 additions & 0 deletions

File tree

packages/language/res/stdlib.zmodel

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,3 +669,11 @@ attribute @meta(_ name: String, _ value: Any)
669669
* Marks an attribute as deprecated.
670670
*/
671671
attribute @@@deprecated(_ message: String)
672+
673+
/**
674+
* Indicates that the field should be encrypted when storing in the database and decrypted when read.
675+
* Only applicable to String fields. The encryption uses AES-256-GCM via the Web Crypto API.
676+
*
677+
* To use this attribute, you must configure encryption options when creating the ZenStackClient.
678+
*/
679+
attribute @encrypted() @@@targetField([StringField])
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import base from '@zenstackhq/eslint-config';
2+
3+
/** @type {import('eslint').Linter.Config[]} */
4+
export default [...base];
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"name": "@zenstackhq/plugin-encryption",
3+
"version": "3.3.2",
4+
"description": "ZenStack Encryption Plugin - Automatic field encryption/decryption for @encrypted fields",
5+
"type": "module",
6+
"scripts": {
7+
"build": "tsc --noEmit && tsup-node",
8+
"watch": "tsup-node --watch",
9+
"lint": "eslint src --ext ts",
10+
"pack": "pnpm pack"
11+
},
12+
"keywords": [
13+
"zenstack",
14+
"encryption",
15+
"aes",
16+
"crypto"
17+
],
18+
"author": "ZenStack Team",
19+
"license": "MIT",
20+
"files": [
21+
"dist"
22+
],
23+
"exports": {
24+
".": {
25+
"import": {
26+
"types": "./dist/index.d.ts",
27+
"default": "./dist/index.js"
28+
},
29+
"require": {
30+
"types": "./dist/index.d.cts",
31+
"default": "./dist/index.cjs"
32+
}
33+
},
34+
"./package.json": {
35+
"import": "./package.json",
36+
"require": "./package.json"
37+
}
38+
},
39+
"dependencies": {
40+
"@zenstackhq/orm": "workspace:*",
41+
"zod": "catalog:"
42+
},
43+
"devDependencies": {
44+
"@zenstackhq/eslint-config": "workspace:*",
45+
"@zenstackhq/typescript-config": "workspace:*",
46+
"@zenstackhq/vitest-config": "workspace:*"
47+
}
48+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { _decrypt, ENCRYPTION_KEY_BYTES, getKeyDigest, loadKey } from './utils.js';
2+
3+
/**
4+
* Default decrypter with support for key rotation
5+
*/
6+
export class Decrypter {
7+
private keys: Array<{ key: CryptoKey; digest: string }> = [];
8+
9+
constructor(private readonly decryptionKeys: Uint8Array[]) {
10+
if (decryptionKeys.length === 0) {
11+
throw new Error('At least one decryption key must be provided');
12+
}
13+
14+
for (const key of decryptionKeys) {
15+
if (key.length !== ENCRYPTION_KEY_BYTES) {
16+
throw new Error(`Decryption key must be ${ENCRYPTION_KEY_BYTES} bytes`);
17+
}
18+
}
19+
}
20+
21+
/**
22+
* Decrypts the given data
23+
*/
24+
async decrypt(data: string): Promise<string> {
25+
if (this.keys.length === 0) {
26+
this.keys = await Promise.all(
27+
this.decryptionKeys.map(async (key) => ({
28+
key: await loadKey(key, ['decrypt']),
29+
digest: await getKeyDigest(key),
30+
})),
31+
);
32+
}
33+
34+
return _decrypt(data, async (digest) =>
35+
this.keys.filter((entry) => entry.digest === digest).map((entry) => entry.key),
36+
);
37+
}
38+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { _encrypt, ENCRYPTION_KEY_BYTES, getKeyDigest, loadKey } from './utils.js';
2+
3+
/**
4+
* Default encrypter using AES-256-GCM
5+
*/
6+
export class Encrypter {
7+
private key: CryptoKey | undefined;
8+
private keyDigest: string | undefined;
9+
10+
constructor(private readonly encryptionKey: Uint8Array) {
11+
if (encryptionKey.length !== ENCRYPTION_KEY_BYTES) {
12+
throw new Error(`Encryption key must be ${ENCRYPTION_KEY_BYTES} bytes`);
13+
}
14+
}
15+
16+
/**
17+
* Encrypts the given data
18+
*/
19+
async encrypt(data: string): Promise<string> {
20+
if (!this.key) {
21+
this.key = await loadKey(this.encryptionKey, ['encrypt']);
22+
}
23+
24+
if (!this.keyDigest) {
25+
this.keyDigest = await getKeyDigest(this.encryptionKey);
26+
}
27+
28+
return _encrypt(data, this.key, this.keyDigest);
29+
}
30+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { Decrypter } from './decrypter.js';
2+
export { Encrypter } from './encrypter.js';
3+
export { createEncryptionPlugin } from './plugin.js';
4+
export type { CustomEncryption, EncryptionConfig, SimpleEncryption } from './types.js';
5+
export { isCustomEncryption } from './types.js';
6+
export { ENCRYPTION_KEY_BYTES } from './utils.js';

0 commit comments

Comments
 (0)