@ultimatelemon-eu/crypto 0.0.0-stage → 1.0.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 +41 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +65 -0
- package/package.json +35 -4
package/README.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ultimatelemon-eu/crypto
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Encrypt secrets before they go into the database: API keys, OAuth tokens,
|
|
4
|
+
passwords for external systems. AES-256-GCM, Node only, no dependencies.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @ultimatelemon-eu/crypto
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { decrypt, encrypt } from '@ultimatelemon-eu/crypto';
|
|
12
|
+
|
|
13
|
+
const sealed = encrypt(apiKey); // 'v1.<iv>.<tag>.<data>'
|
|
14
|
+
const apiKey = decrypt(sealed);
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Key
|
|
18
|
+
|
|
19
|
+
`ENCRYPTION_KEY`: 32 random bytes, base64.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Read on first use, not at import, so a build without secrets works. Missing
|
|
26
|
+
or not 32 bytes throws; there is no fallback key. Losing the key means losing
|
|
27
|
+
every value sealed with it.
|
|
28
|
+
|
|
29
|
+
## API
|
|
30
|
+
|
|
31
|
+
| Function | Returns | Notes |
|
|
32
|
+
| ----------------------- | --------- | ------------------------------------------------------ |
|
|
33
|
+
| `encrypt(plaintext)` | `string` | `v1.<iv>.<tag>.<data>`, base64url, fresh IV every call |
|
|
34
|
+
| `decrypt(sealed)` | `string` | Throws on tampering, a wrong key or a non-sealed value |
|
|
35
|
+
| `isEncrypted(value)` | `boolean` | Type guard for the `v1.` format |
|
|
36
|
+
| `randomToken(bytes=32)` | `string` | base64url, for invite links, CSRF, state |
|
|
37
|
+
| `sha256Hex(value)` | `string` | Store a token's hash, not the token |
|
|
38
|
+
|
|
39
|
+
`decrypt` also reads the same format written with standard base64, which is
|
|
40
|
+
what Cellar stored before this package.
|
|
41
|
+
|
|
42
|
+
Store sealed values in a `text` column.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function encrypt(plaintext: string): string;
|
|
2
|
+
export declare function decrypt(sealed: string): string;
|
|
3
|
+
export declare function isEncrypted(value: unknown): value is string;
|
|
4
|
+
export declare function randomToken(bytes?: number): string;
|
|
5
|
+
export declare function sha256Hex(value: string): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
3
|
+
const VERSION = 'v1';
|
|
4
|
+
const KEY_BYTES = 32;
|
|
5
|
+
const IV_BYTES = 12;
|
|
6
|
+
const TAG_BYTES = 16;
|
|
7
|
+
const SEALED = /^v1\.[\w+/=-]+\.[\w+/=-]+\.[\w+/=-]*$/;
|
|
8
|
+
let cached = null;
|
|
9
|
+
// Read on first use, not at import, so builds without secrets still work.
|
|
10
|
+
function key() {
|
|
11
|
+
const raw = process.env.ENCRYPTION_KEY;
|
|
12
|
+
if (!raw)
|
|
13
|
+
throw new Error('ENCRYPTION_KEY is not set');
|
|
14
|
+
if (cached?.raw === raw)
|
|
15
|
+
return cached.key;
|
|
16
|
+
const key = Buffer.from(raw, 'base64');
|
|
17
|
+
if (key.length !== KEY_BYTES) {
|
|
18
|
+
throw new Error(`ENCRYPTION_KEY must be ${KEY_BYTES} bytes of base64, got ${key.length} bytes`);
|
|
19
|
+
}
|
|
20
|
+
cached = { raw, key };
|
|
21
|
+
return key;
|
|
22
|
+
}
|
|
23
|
+
export function encrypt(plaintext) {
|
|
24
|
+
const iv = randomBytes(IV_BYTES);
|
|
25
|
+
const cipher = createCipheriv(ALGORITHM, key(), iv, {
|
|
26
|
+
authTagLength: TAG_BYTES
|
|
27
|
+
});
|
|
28
|
+
const data = Buffer.concat([
|
|
29
|
+
cipher.update(plaintext, 'utf8'),
|
|
30
|
+
cipher.final()
|
|
31
|
+
]);
|
|
32
|
+
const tag = cipher.getAuthTag();
|
|
33
|
+
return [VERSION, iv, tag, data].map(encode).join('.');
|
|
34
|
+
}
|
|
35
|
+
// Throws when the value was tampered with or sealed under another key.
|
|
36
|
+
export function decrypt(sealed) {
|
|
37
|
+
if (!isEncrypted(sealed))
|
|
38
|
+
throw new Error('Not an encrypted value');
|
|
39
|
+
const [, iv, tag, data] = sealed.split('.').map(decode);
|
|
40
|
+
if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
|
|
41
|
+
throw new Error('Not an encrypted value');
|
|
42
|
+
}
|
|
43
|
+
const decipher = createDecipheriv(ALGORITHM, key(), iv, {
|
|
44
|
+
authTagLength: TAG_BYTES
|
|
45
|
+
});
|
|
46
|
+
decipher.setAuthTag(tag);
|
|
47
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
|
48
|
+
}
|
|
49
|
+
export function isEncrypted(value) {
|
|
50
|
+
return typeof value === 'string' && SEALED.test(value);
|
|
51
|
+
}
|
|
52
|
+
export function randomToken(bytes = 32) {
|
|
53
|
+
return randomBytes(bytes).toString('base64url');
|
|
54
|
+
}
|
|
55
|
+
export function sha256Hex(value) {
|
|
56
|
+
return createHash('sha256').update(value).digest('hex');
|
|
57
|
+
}
|
|
58
|
+
function encode(part) {
|
|
59
|
+
return typeof part === 'string' ? part : part.toString('base64url');
|
|
60
|
+
}
|
|
61
|
+
// Node's base64 decoder also reads base64url, so values written as either
|
|
62
|
+
// decrypt the same.
|
|
63
|
+
function decode(part) {
|
|
64
|
+
return Buffer.from(part, 'base64');
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimatelemon-eu/crypto",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AES-256-GCM encryption for secrets at rest, keyed by ENCRYPTION_KEY, plus random tokens and SHA-256. Node only, no dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ultimatelemon/packages.git",
|
|
9
|
+
"directory": "packages/crypto"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc --project tsconfig.json",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^26.5.0",
|
|
31
|
+
"typescript": "^5.9.3",
|
|
32
|
+
"vitest": "^5.0.1"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|