@nixxie-cms/fields-encrypted 1.0.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/LICENSE +23 -0
- package/README.md +19 -0
- package/dist/declarations/src/crypto.d.ts +7 -0
- package/dist/declarations/src/crypto.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +20 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/views.d.ts +5 -0
- package/dist/declarations/src/views.d.ts.map +1 -0
- package/dist/nixxie-cms-fields-encrypted.cjs.d.ts +2 -0
- package/dist/nixxie-cms-fields-encrypted.cjs.js +111 -0
- package/dist/nixxie-cms-fields-encrypted.esm.js +104 -0
- package/package.json +43 -0
- package/src/crypto.ts +35 -0
- package/src/index.ts +82 -0
- package/src/views.tsx +33 -0
- package/views/dist/nixxie-cms-fields-encrypted-views.cjs.d.ts +2 -0
- package/views/dist/nixxie-cms-fields-encrypted-views.cjs.js +43 -0
- package/views/dist/nixxie-cms-fields-encrypted-views.esm.js +38 -0
- package/views/package.json +4 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixxie International DMCC
|
|
4
|
+
Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
|
|
5
|
+
(this software is derived from the KeystoneJS project, https://keystonejs.com)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @nixxie-cms/fields-encrypted
|
|
2
|
+
|
|
3
|
+
A field that is transparently encrypted at rest for Nixxie CMS. Values are encrypted with
|
|
4
|
+
AES-256-GCM before being written to the database and decrypted when read back through GraphQL — the
|
|
5
|
+
plaintext is never stored on disk.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { encrypted } from '@nixxie-cms/fields-encrypted'
|
|
9
|
+
|
|
10
|
+
fields: {
|
|
11
|
+
ssn: encrypted({ secret: process.env.FIELD_ENCRYPTION_KEY! }),
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- Keep `secret` out of source control (use an env var). Rotating it makes previously-stored values
|
|
16
|
+
undecryptable, so plan a re-encryption migration if you must rotate.
|
|
17
|
+
- The stored column holds ciphertext (`enc:v1:<iv>:<tag>:<data>`), so it is not searchable or
|
|
18
|
+
sortable on plaintext.
|
|
19
|
+
- `encrypt`, `decrypt` and `isEncrypted` helpers are also exported for migrations.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Encrypt a string with AES-256-GCM. Output: `enc:v1:<iv>:<tag>:<ciphertext>` (all base64). */
|
|
2
|
+
export declare function encrypt(plaintext: string, secret: string): string;
|
|
3
|
+
/** Whether a stored value looks like ciphertext produced by `encrypt`. */
|
|
4
|
+
export declare function isEncrypted(value: string): boolean;
|
|
5
|
+
/** Decrypt a value produced by `encrypt`. Returns the input unchanged if it isn't encrypted. */
|
|
6
|
+
export declare function decrypt(value: string, secret: string): string;
|
|
7
|
+
//# sourceMappingURL=crypto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"../../../src","sources":["crypto.ts"],"names":[],"mappings":"AASA,gGAAgG;AAChG,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAOjE;AAED,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAElD;AAED,gGAAgG;AAChG,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAS7D"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type BaseListTypeInfo, type CommonFieldConfig, type FieldTypeFunc, type SimpleFieldTypeInfo } from '@nixxie-cms/core/types';
|
|
2
|
+
export type EncryptedFieldConfig<ListTypeInfo extends BaseListTypeInfo> = CommonFieldConfig<ListTypeInfo, SimpleFieldTypeInfo<'String'>> & {
|
|
3
|
+
/**
|
|
4
|
+
* Encryption secret. Data is encrypted at rest with AES-256-GCM using a key derived from this
|
|
5
|
+
* value. Store it outside source control (e.g. `process.env.FIELD_ENCRYPTION_KEY`). Rotating it
|
|
6
|
+
* makes previously-stored values undecryptable.
|
|
7
|
+
*/
|
|
8
|
+
secret: string;
|
|
9
|
+
db?: {
|
|
10
|
+
isNullable?: boolean;
|
|
11
|
+
map?: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A field whose value is transparently encrypted (AES-256-GCM) before being written to the
|
|
16
|
+
* database and decrypted when read back through GraphQL. The plaintext never touches disk.
|
|
17
|
+
*/
|
|
18
|
+
export declare function encrypted<ListTypeInfo extends BaseListTypeInfo>(config: EncryptedFieldConfig<ListTypeInfo>): FieldTypeFunc<ListTypeInfo>;
|
|
19
|
+
export { encrypt, decrypt, isEncrypted } from "./crypto.js";
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,KAAK,mBAAmB,EAGzB,MAAM,wBAAwB,CAAA;AAG/B,MAAM,MAAM,oBAAoB,CAAC,YAAY,SAAS,gBAAgB,IAAI,iBAAiB,CACzF,YAAY,EACZ,mBAAmB,CAAC,QAAQ,CAAC,CAC9B,GAAG;IACF;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAA;IACd,EAAE,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,OAAO,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAC5C,CAAA;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,YAAY,SAAS,gBAAgB,EAC7D,MAAM,EAAE,oBAAoB,CAAC,YAAY,CAAC,GACzC,aAAa,CAAC,YAAY,CAAC,CAiD7B;AAED,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,oBAAgB"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FieldController, FieldControllerConfig, FieldProps } from '@nixxie-cms/core/types';
|
|
2
|
+
/** Item-page / create-modal input. The value shown is the decrypted plaintext. */
|
|
3
|
+
export declare function Field({ autoFocus, field, onChange, value }: FieldProps<typeof controller>): import("react/jsx-runtime").JSX.Element;
|
|
4
|
+
export declare function controller(config: FieldControllerConfig<Record<string, never>>): FieldController<string | null, string>;
|
|
5
|
+
//# sourceMappingURL=views.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"views.d.ts","sourceRoot":"../../../src","sources":["views.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,qBAAqB,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AAEhG,kFAAkF;AAClF,wBAAgB,KAAK,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,2CAWzF;AAED,wBAAgB,UAAU,CACxB,MAAM,EAAE,qBAAqB,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,GACnD,eAAe,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,CAaxC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "./declarations/src/index.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1maWVsZHMtZW5jcnlwdGVkLmNqcy5kLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi9kZWNsYXJhdGlvbnMvc3JjL2luZGV4LmQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEifQ==
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var core = require('@nixxie-cms/core');
|
|
6
|
+
var types = require('@nixxie-cms/core/types');
|
|
7
|
+
var node_crypto = require('node:crypto');
|
|
8
|
+
|
|
9
|
+
const PREFIX = 'enc:v1:';
|
|
10
|
+
function deriveKey(secret) {
|
|
11
|
+
// Deterministic 32-byte key from the configured secret.
|
|
12
|
+
return node_crypto.scryptSync(secret, 'nixxie-encrypted-field', 32);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Encrypt a string with AES-256-GCM. Output: `enc:v1:<iv>:<tag>:<ciphertext>` (all base64). */
|
|
16
|
+
function encrypt(plaintext, secret) {
|
|
17
|
+
const key = deriveKey(secret);
|
|
18
|
+
const iv = node_crypto.randomBytes(12);
|
|
19
|
+
const cipher = node_crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
20
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
21
|
+
const tag = cipher.getAuthTag();
|
|
22
|
+
return `${PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${ciphertext.toString('base64')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether a stored value looks like ciphertext produced by `encrypt`. */
|
|
26
|
+
function isEncrypted(value) {
|
|
27
|
+
return value.startsWith(PREFIX);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Decrypt a value produced by `encrypt`. Returns the input unchanged if it isn't encrypted. */
|
|
31
|
+
function decrypt(value, secret) {
|
|
32
|
+
if (!isEncrypted(value)) return value;
|
|
33
|
+
const [,, ivB64, tagB64, dataB64] = value.split(':');
|
|
34
|
+
const key = deriveKey(secret);
|
|
35
|
+
const decipher = node_crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'));
|
|
36
|
+
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
|
|
37
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A field whose value is transparently encrypted (AES-256-GCM) before being written to the
|
|
42
|
+
* database and decrypted when read back through GraphQL. The plaintext never touches disk.
|
|
43
|
+
*/
|
|
44
|
+
function encrypted(config) {
|
|
45
|
+
if (!config.secret) {
|
|
46
|
+
throw new Error('@nixxie-cms/fields-encrypted: a `secret` is required');
|
|
47
|
+
}
|
|
48
|
+
const {
|
|
49
|
+
secret
|
|
50
|
+
} = config;
|
|
51
|
+
return () => {
|
|
52
|
+
var _config$db;
|
|
53
|
+
return types.fieldType({
|
|
54
|
+
kind: 'scalar',
|
|
55
|
+
mode: 'optional',
|
|
56
|
+
scalar: 'String',
|
|
57
|
+
map: (_config$db = config.db) === null || _config$db === void 0 ? void 0 : _config$db.map
|
|
58
|
+
})({
|
|
59
|
+
...config,
|
|
60
|
+
input: {
|
|
61
|
+
create: {
|
|
62
|
+
arg: core.g.arg({
|
|
63
|
+
type: core.g.String
|
|
64
|
+
}),
|
|
65
|
+
resolve(val) {
|
|
66
|
+
if (val == null) return val;
|
|
67
|
+
return encrypt(val, secret);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
update: {
|
|
71
|
+
arg: core.g.arg({
|
|
72
|
+
type: core.g.String
|
|
73
|
+
}),
|
|
74
|
+
resolve(val) {
|
|
75
|
+
if (val === undefined) return undefined;
|
|
76
|
+
if (val === null) return null;
|
|
77
|
+
return encrypt(val, secret);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
orderBy: {
|
|
81
|
+
arg: core.g.arg({
|
|
82
|
+
type: types.orderDirectionEnum
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
output: core.g.field({
|
|
87
|
+
type: core.g.String,
|
|
88
|
+
resolve({
|
|
89
|
+
value
|
|
90
|
+
}) {
|
|
91
|
+
if (value == null) return value;
|
|
92
|
+
try {
|
|
93
|
+
return decrypt(value, secret);
|
|
94
|
+
} catch {
|
|
95
|
+
// Wrong key or corrupted data — return null rather than leaking ciphertext.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}),
|
|
100
|
+
views: '@nixxie-cms/fields-encrypted/views',
|
|
101
|
+
getAdminMeta() {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
exports.decrypt = decrypt;
|
|
109
|
+
exports.encrypt = encrypt;
|
|
110
|
+
exports.encrypted = encrypted;
|
|
111
|
+
exports.isEncrypted = isEncrypted;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { g } from '@nixxie-cms/core';
|
|
2
|
+
import { fieldType, orderDirectionEnum } from '@nixxie-cms/core/types';
|
|
3
|
+
import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const PREFIX = 'enc:v1:';
|
|
6
|
+
function deriveKey(secret) {
|
|
7
|
+
// Deterministic 32-byte key from the configured secret.
|
|
8
|
+
return scryptSync(secret, 'nixxie-encrypted-field', 32);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Encrypt a string with AES-256-GCM. Output: `enc:v1:<iv>:<tag>:<ciphertext>` (all base64). */
|
|
12
|
+
function encrypt(plaintext, secret) {
|
|
13
|
+
const key = deriveKey(secret);
|
|
14
|
+
const iv = randomBytes(12);
|
|
15
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
16
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
17
|
+
const tag = cipher.getAuthTag();
|
|
18
|
+
return `${PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${ciphertext.toString('base64')}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Whether a stored value looks like ciphertext produced by `encrypt`. */
|
|
22
|
+
function isEncrypted(value) {
|
|
23
|
+
return value.startsWith(PREFIX);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Decrypt a value produced by `encrypt`. Returns the input unchanged if it isn't encrypted. */
|
|
27
|
+
function decrypt(value, secret) {
|
|
28
|
+
if (!isEncrypted(value)) return value;
|
|
29
|
+
const [,, ivB64, tagB64, dataB64] = value.split(':');
|
|
30
|
+
const key = deriveKey(secret);
|
|
31
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'));
|
|
32
|
+
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
|
|
33
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A field whose value is transparently encrypted (AES-256-GCM) before being written to the
|
|
38
|
+
* database and decrypted when read back through GraphQL. The plaintext never touches disk.
|
|
39
|
+
*/
|
|
40
|
+
function encrypted(config) {
|
|
41
|
+
if (!config.secret) {
|
|
42
|
+
throw new Error('@nixxie-cms/fields-encrypted: a `secret` is required');
|
|
43
|
+
}
|
|
44
|
+
const {
|
|
45
|
+
secret
|
|
46
|
+
} = config;
|
|
47
|
+
return () => {
|
|
48
|
+
var _config$db;
|
|
49
|
+
return fieldType({
|
|
50
|
+
kind: 'scalar',
|
|
51
|
+
mode: 'optional',
|
|
52
|
+
scalar: 'String',
|
|
53
|
+
map: (_config$db = config.db) === null || _config$db === void 0 ? void 0 : _config$db.map
|
|
54
|
+
})({
|
|
55
|
+
...config,
|
|
56
|
+
input: {
|
|
57
|
+
create: {
|
|
58
|
+
arg: g.arg({
|
|
59
|
+
type: g.String
|
|
60
|
+
}),
|
|
61
|
+
resolve(val) {
|
|
62
|
+
if (val == null) return val;
|
|
63
|
+
return encrypt(val, secret);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
update: {
|
|
67
|
+
arg: g.arg({
|
|
68
|
+
type: g.String
|
|
69
|
+
}),
|
|
70
|
+
resolve(val) {
|
|
71
|
+
if (val === undefined) return undefined;
|
|
72
|
+
if (val === null) return null;
|
|
73
|
+
return encrypt(val, secret);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
orderBy: {
|
|
77
|
+
arg: g.arg({
|
|
78
|
+
type: orderDirectionEnum
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
output: g.field({
|
|
83
|
+
type: g.String,
|
|
84
|
+
resolve({
|
|
85
|
+
value
|
|
86
|
+
}) {
|
|
87
|
+
if (value == null) return value;
|
|
88
|
+
try {
|
|
89
|
+
return decrypt(value, secret);
|
|
90
|
+
} catch {
|
|
91
|
+
// Wrong key or corrupted data — return null rather than leaking ciphertext.
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
views: '@nixxie-cms/fields-encrypted/views',
|
|
97
|
+
getAdminMeta() {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { decrypt, encrypt, encrypted, isEncrypted };
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/fields-encrypted",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-fields-encrypted.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-fields-encrypted.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-fields-encrypted.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-fields-encrypted.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-fields-encrypted.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./views": {
|
|
14
|
+
"types": "./views/dist/nixxie-cms-fields-encrypted-views.cjs.js",
|
|
15
|
+
"module": "./views/dist/nixxie-cms-fields-encrypted-views.esm.js",
|
|
16
|
+
"default": "./views/dist/nixxie-cms-fields-encrypted-views.cjs.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@babel/runtime": "^7.24.7",
|
|
22
|
+
"@keystar/ui": "^0.7.21",
|
|
23
|
+
"graphql": "^16.8.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"react": "^19.2.4",
|
|
27
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@nixxie-cms/core": "^1.0.1",
|
|
31
|
+
"react": "^19.2.4"
|
|
32
|
+
},
|
|
33
|
+
"preconstruct": {
|
|
34
|
+
"entrypoints": [
|
|
35
|
+
"index.ts",
|
|
36
|
+
"views.tsx"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/fields-encrypted"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
const PREFIX = 'enc:v1:'
|
|
4
|
+
|
|
5
|
+
function deriveKey(secret: string): Buffer {
|
|
6
|
+
// Deterministic 32-byte key from the configured secret.
|
|
7
|
+
return scryptSync(secret, 'nixxie-encrypted-field', 32)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Encrypt a string with AES-256-GCM. Output: `enc:v1:<iv>:<tag>:<ciphertext>` (all base64). */
|
|
11
|
+
export function encrypt(plaintext: string, secret: string): string {
|
|
12
|
+
const key = deriveKey(secret)
|
|
13
|
+
const iv = randomBytes(12)
|
|
14
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
15
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
16
|
+
const tag = cipher.getAuthTag()
|
|
17
|
+
return `${PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${ciphertext.toString('base64')}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Whether a stored value looks like ciphertext produced by `encrypt`. */
|
|
21
|
+
export function isEncrypted(value: string): boolean {
|
|
22
|
+
return value.startsWith(PREFIX)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Decrypt a value produced by `encrypt`. Returns the input unchanged if it isn't encrypted. */
|
|
26
|
+
export function decrypt(value: string, secret: string): string {
|
|
27
|
+
if (!isEncrypted(value)) return value
|
|
28
|
+
const [, , ivB64, tagB64, dataB64] = value.split(':')
|
|
29
|
+
const key = deriveKey(secret)
|
|
30
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'))
|
|
31
|
+
decipher.setAuthTag(Buffer.from(tagB64, 'base64'))
|
|
32
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString(
|
|
33
|
+
'utf8'
|
|
34
|
+
)
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { g } from '@nixxie-cms/core'
|
|
2
|
+
import {
|
|
3
|
+
type BaseListTypeInfo,
|
|
4
|
+
type CommonFieldConfig,
|
|
5
|
+
type FieldTypeFunc,
|
|
6
|
+
type SimpleFieldTypeInfo,
|
|
7
|
+
fieldType,
|
|
8
|
+
orderDirectionEnum,
|
|
9
|
+
} from '@nixxie-cms/core/types'
|
|
10
|
+
import { decrypt, encrypt } from './crypto'
|
|
11
|
+
|
|
12
|
+
export type EncryptedFieldConfig<ListTypeInfo extends BaseListTypeInfo> = CommonFieldConfig<
|
|
13
|
+
ListTypeInfo,
|
|
14
|
+
SimpleFieldTypeInfo<'String'>
|
|
15
|
+
> & {
|
|
16
|
+
/**
|
|
17
|
+
* Encryption secret. Data is encrypted at rest with AES-256-GCM using a key derived from this
|
|
18
|
+
* value. Store it outside source control (e.g. `process.env.FIELD_ENCRYPTION_KEY`). Rotating it
|
|
19
|
+
* makes previously-stored values undecryptable.
|
|
20
|
+
*/
|
|
21
|
+
secret: string
|
|
22
|
+
db?: { isNullable?: boolean; map?: string }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A field whose value is transparently encrypted (AES-256-GCM) before being written to the
|
|
27
|
+
* database and decrypted when read back through GraphQL. The plaintext never touches disk.
|
|
28
|
+
*/
|
|
29
|
+
export function encrypted<ListTypeInfo extends BaseListTypeInfo>(
|
|
30
|
+
config: EncryptedFieldConfig<ListTypeInfo>
|
|
31
|
+
): FieldTypeFunc<ListTypeInfo> {
|
|
32
|
+
if (!config.secret) {
|
|
33
|
+
throw new Error('@nixxie-cms/fields-encrypted: a `secret` is required')
|
|
34
|
+
}
|
|
35
|
+
const { secret } = config
|
|
36
|
+
|
|
37
|
+
return () =>
|
|
38
|
+
fieldType({
|
|
39
|
+
kind: 'scalar',
|
|
40
|
+
mode: 'optional',
|
|
41
|
+
scalar: 'String',
|
|
42
|
+
map: config.db?.map,
|
|
43
|
+
})({
|
|
44
|
+
...config,
|
|
45
|
+
input: {
|
|
46
|
+
create: {
|
|
47
|
+
arg: g.arg({ type: g.String }),
|
|
48
|
+
resolve(val) {
|
|
49
|
+
if (val == null) return val
|
|
50
|
+
return encrypt(val, secret)
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
update: {
|
|
54
|
+
arg: g.arg({ type: g.String }),
|
|
55
|
+
resolve(val) {
|
|
56
|
+
if (val === undefined) return undefined
|
|
57
|
+
if (val === null) return null
|
|
58
|
+
return encrypt(val, secret)
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
orderBy: { arg: g.arg({ type: orderDirectionEnum }) },
|
|
62
|
+
},
|
|
63
|
+
output: g.field({
|
|
64
|
+
type: g.String,
|
|
65
|
+
resolve({ value }) {
|
|
66
|
+
if (value == null) return value
|
|
67
|
+
try {
|
|
68
|
+
return decrypt(value, secret)
|
|
69
|
+
} catch {
|
|
70
|
+
// Wrong key or corrupted data — return null rather than leaking ciphertext.
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
views: '@nixxie-cms/fields-encrypted/views',
|
|
76
|
+
getAdminMeta() {
|
|
77
|
+
return {}
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { encrypt, decrypt, isEncrypted } from './crypto'
|
package/src/views.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { TextField } from '@keystar/ui/text-field'
|
|
2
|
+
import type { FieldController, FieldControllerConfig, FieldProps } from '@nixxie-cms/core/types'
|
|
3
|
+
|
|
4
|
+
/** Item-page / create-modal input. The value shown is the decrypted plaintext. */
|
|
5
|
+
export function Field({ autoFocus, field, onChange, value }: FieldProps<typeof controller>) {
|
|
6
|
+
return (
|
|
7
|
+
<TextField
|
|
8
|
+
label={field.label}
|
|
9
|
+
description={field.description || undefined}
|
|
10
|
+
autoFocus={autoFocus}
|
|
11
|
+
isReadOnly={onChange === undefined}
|
|
12
|
+
value={value ?? ''}
|
|
13
|
+
onChange={onChange ? text => onChange(text === '' ? null : text) : undefined}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function controller(
|
|
19
|
+
config: FieldControllerConfig<Record<string, never>>
|
|
20
|
+
): FieldController<string | null, string> {
|
|
21
|
+
return {
|
|
22
|
+
fieldKey: config.fieldKey,
|
|
23
|
+
label: config.label,
|
|
24
|
+
description: config.description ?? '',
|
|
25
|
+
defaultValue: null,
|
|
26
|
+
deserialize: data => {
|
|
27
|
+
const value = data[config.fieldKey]
|
|
28
|
+
return typeof value === 'string' ? value : null
|
|
29
|
+
},
|
|
30
|
+
serialize: value => ({ [config.fieldKey]: value === '' ? null : value }),
|
|
31
|
+
graphqlSelection: config.fieldKey,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "../../dist/declarations/src/views.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1maWVsZHMtZW5jcnlwdGVkLXZpZXdzLmNqcy5kLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vZGlzdC9kZWNsYXJhdGlvbnMvc3JjL3ZpZXdzLmQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEifQ==
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var textField = require('@keystar/ui/text-field');
|
|
6
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
7
|
+
|
|
8
|
+
/** Item-page / create-modal input. The value shown is the decrypted plaintext. */
|
|
9
|
+
function Field({
|
|
10
|
+
autoFocus,
|
|
11
|
+
field,
|
|
12
|
+
onChange,
|
|
13
|
+
value
|
|
14
|
+
}) {
|
|
15
|
+
return /*#__PURE__*/jsxRuntime.jsx(textField.TextField, {
|
|
16
|
+
label: field.label,
|
|
17
|
+
description: field.description || undefined,
|
|
18
|
+
autoFocus: autoFocus,
|
|
19
|
+
isReadOnly: onChange === undefined,
|
|
20
|
+
value: value !== null && value !== void 0 ? value : '',
|
|
21
|
+
onChange: onChange ? text => onChange(text === '' ? null : text) : undefined
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function controller(config) {
|
|
25
|
+
var _config$description;
|
|
26
|
+
return {
|
|
27
|
+
fieldKey: config.fieldKey,
|
|
28
|
+
label: config.label,
|
|
29
|
+
description: (_config$description = config.description) !== null && _config$description !== void 0 ? _config$description : '',
|
|
30
|
+
defaultValue: null,
|
|
31
|
+
deserialize: data => {
|
|
32
|
+
const value = data[config.fieldKey];
|
|
33
|
+
return typeof value === 'string' ? value : null;
|
|
34
|
+
},
|
|
35
|
+
serialize: value => ({
|
|
36
|
+
[config.fieldKey]: value === '' ? null : value
|
|
37
|
+
}),
|
|
38
|
+
graphqlSelection: config.fieldKey
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
exports.Field = Field;
|
|
43
|
+
exports.controller = controller;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { TextField } from '@keystar/ui/text-field';
|
|
2
|
+
import { jsx } from 'react/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
/** Item-page / create-modal input. The value shown is the decrypted plaintext. */
|
|
5
|
+
function Field({
|
|
6
|
+
autoFocus,
|
|
7
|
+
field,
|
|
8
|
+
onChange,
|
|
9
|
+
value
|
|
10
|
+
}) {
|
|
11
|
+
return /*#__PURE__*/jsx(TextField, {
|
|
12
|
+
label: field.label,
|
|
13
|
+
description: field.description || undefined,
|
|
14
|
+
autoFocus: autoFocus,
|
|
15
|
+
isReadOnly: onChange === undefined,
|
|
16
|
+
value: value !== null && value !== void 0 ? value : '',
|
|
17
|
+
onChange: onChange ? text => onChange(text === '' ? null : text) : undefined
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function controller(config) {
|
|
21
|
+
var _config$description;
|
|
22
|
+
return {
|
|
23
|
+
fieldKey: config.fieldKey,
|
|
24
|
+
label: config.label,
|
|
25
|
+
description: (_config$description = config.description) !== null && _config$description !== void 0 ? _config$description : '',
|
|
26
|
+
defaultValue: null,
|
|
27
|
+
deserialize: data => {
|
|
28
|
+
const value = data[config.fieldKey];
|
|
29
|
+
return typeof value === 'string' ? value : null;
|
|
30
|
+
},
|
|
31
|
+
serialize: value => ({
|
|
32
|
+
[config.fieldKey]: value === '' ? null : value
|
|
33
|
+
}),
|
|
34
|
+
graphqlSelection: config.fieldKey
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { Field, controller };
|