@ultimatelemon-eu/crypto 0.0.0-stage → 1.1.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 CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # @ultimatelemon-eu/crypto
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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, { context? })` | `string` | `v1.<iv>.<tag>.<data>`, base64url, fresh IV every call |
34
+ | `decrypt(sealed, { context? })` | `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.
43
+
44
+ ## Context
45
+
46
+ Pass the row a value belongs to as `context`, and it only decrypts with that
47
+ same context. A sealed password copied into another user's row then fails
48
+ instead of opening their mailbox. The context is not stored; supply it again
49
+ when decrypting.
50
+
51
+ ```ts
52
+ const sealed = encrypt(password, { context: `${userId}:${accountId}` });
53
+ const password = decrypt(sealed, { context: `${userId}:${accountId}` });
54
+ ```
@@ -0,0 +1,12 @@
1
+ export interface SealOptions {
2
+ /**
3
+ * Bound into the tag but not stored: decrypting needs the same value, so a
4
+ * sealed value copied to another row (another user's id) no longer opens.
5
+ */
6
+ context?: string;
7
+ }
8
+ export declare function encrypt(plaintext: string, options?: SealOptions): string;
9
+ export declare function decrypt(sealed: string, options?: SealOptions): string;
10
+ export declare function isEncrypted(value: unknown): value is string;
11
+ export declare function randomToken(bytes?: number): string;
12
+ export declare function sha256Hex(value: string): string;
package/dist/index.js ADDED
@@ -0,0 +1,71 @@
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, options = {}) {
24
+ const iv = randomBytes(IV_BYTES);
25
+ const cipher = createCipheriv(ALGORITHM, key(), iv, {
26
+ authTagLength: TAG_BYTES
27
+ });
28
+ if (options.context !== undefined) {
29
+ cipher.setAAD(Buffer.from(options.context, 'utf8'));
30
+ }
31
+ const data = Buffer.concat([
32
+ cipher.update(plaintext, 'utf8'),
33
+ cipher.final()
34
+ ]);
35
+ const tag = cipher.getAuthTag();
36
+ return [VERSION, iv, tag, data].map(encode).join('.');
37
+ }
38
+ // Throws when the value was tampered with or sealed under another key.
39
+ export function decrypt(sealed, options = {}) {
40
+ if (!isEncrypted(sealed))
41
+ throw new Error('Not an encrypted value');
42
+ const [, iv, tag, data] = sealed.split('.').map(decode);
43
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
44
+ throw new Error('Not an encrypted value');
45
+ }
46
+ const decipher = createDecipheriv(ALGORITHM, key(), iv, {
47
+ authTagLength: TAG_BYTES
48
+ });
49
+ if (options.context !== undefined) {
50
+ decipher.setAAD(Buffer.from(options.context, 'utf8'));
51
+ }
52
+ decipher.setAuthTag(tag);
53
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
54
+ }
55
+ export function isEncrypted(value) {
56
+ return typeof value === 'string' && SEALED.test(value);
57
+ }
58
+ export function randomToken(bytes = 32) {
59
+ return randomBytes(bytes).toString('base64url');
60
+ }
61
+ export function sha256Hex(value) {
62
+ return createHash('sha256').update(value).digest('hex');
63
+ }
64
+ function encode(part) {
65
+ return typeof part === 'string' ? part : part.toString('base64url');
66
+ }
67
+ // Node's base64 decoder also reads base64url, so values written as either
68
+ // decrypt the same.
69
+ function decode(part) {
70
+ return Buffer.from(part, 'base64');
71
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@ultimatelemon-eu/crypto",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.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
+ }