Skip to content

Commit 52b5d29

Browse files
committed
feat (cipher-kit):hashObj added, injected secret key, more tests
1 parent 9fa7cff commit 52b5d29

File tree

9 files changed

+234
-59
lines changed

9 files changed

+234
-59
lines changed

packages/cipher-kit/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cipher-kit",
3-
"version": "2.0.0-beta.2",
3+
"version": "2.0.0-beta.3",
44
"description": "🔐 Secure, Lightweight, and Cross-Platform Encryption, Decryption, and Hashing for Web, Node.js, Deno, Bun, and Cloudflare Workers",
55
"homepage": "https://github.com/WolfieLeader/npm/tree/main/packages/cipher-kit#readme",
66
"repository": {

packages/cipher-kit/src/__tests__/encrypt.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const secret = 'Secret0123456789Secret0123456789';
55
const data = '🦊 secret stuff ~ !@#$%^&*()_+';
66

77
describe('Encryption', () => {
8-
test('Encrypt Data', async () => {
8+
test('Encrypt Data Default', async () => {
99
// biome-ignore lint/style/useConst: ignore
1010
let nodeSecretKey: SecretKey<'node'>;
1111
const nodeKey = nodeKit.tryCreateSecretKey(secret);
@@ -74,6 +74,89 @@ describe('Encryption', () => {
7474
expect(decryptedObjWeb.success).toBe(true);
7575
expect(decryptedObjWeb.result).toEqual(largeObj);
7676
});
77+
78+
test('Encrypt Data AES128GCM', async () => {
79+
// biome-ignore lint/style/useConst: ignore
80+
let nodeSecretKey: SecretKey<'node'>;
81+
const nodeKey = nodeKit.tryCreateSecretKey(secret, { algorithm: 'aes128gcm' });
82+
expect(nodeKey.success).toBe(true);
83+
expect(nodeKey.result).toBeDefined();
84+
expect(isSecretKey(nodeKey.result, 'node')).toBe(true);
85+
nodeSecretKey = nodeKey.result as SecretKey<'node'>;
86+
87+
// biome-ignore lint/style/useConst: ignore
88+
let webSecretKey: SecretKey<'web'>;
89+
const webKey = await webKit.tryCreateSecretKey(secret, { algorithm: 'aes128gcm' });
90+
expect(webKey.success).toBe(true);
91+
expect(webKey.result).toBeDefined();
92+
expect(isSecretKey(webKey.result, 'web')).toBe(true);
93+
webSecretKey = webKey.result as SecretKey<'web'>;
94+
95+
const encryptedNode = nodeKit.tryEncrypt(data, nodeSecretKey, { outputEncoding: 'hex' });
96+
expect(encryptedNode.success).toBe(true);
97+
expect(encryptedNode.result).toBeDefined();
98+
expect(matchPattern(encryptedNode.result as string, 'node')).toBe(true);
99+
100+
const encryptedWeb = await webKit.tryEncrypt(data, webSecretKey, { outputEncoding: 'hex' });
101+
expect(encryptedWeb.success).toBe(true);
102+
expect(encryptedWeb.result).toBeDefined();
103+
expect(matchPattern(encryptedWeb.result as string, 'web')).toBe(true);
104+
105+
expect(encryptedNode.result).not.toBe(encryptedWeb.result);
106+
107+
const decryptedNode = nodeKit.tryDecrypt(encryptedNode.result as string, nodeSecretKey, { inputEncoding: 'hex' });
108+
expect(decryptedNode.success).toBe(true);
109+
expect(decryptedNode.result).toBe(data);
110+
111+
const decryptedWeb = await webKit.tryDecrypt(encryptedWeb.result as string, webSecretKey, { inputEncoding: 'hex' });
112+
expect(decryptedWeb.success).toBe(true);
113+
expect(decryptedWeb.result).toBe(data);
114+
115+
expect(decryptedNode.result).toBe(decryptedWeb.result);
116+
117+
const encryptedNode2 = nodeKit.tryEncrypt(data, nodeSecretKey, { outputEncoding: 'hex' });
118+
expect(encryptedNode2.success).toBe(true);
119+
expect(encryptedNode2.result).toBeDefined();
120+
expect(encryptedNode2.result).not.toBe(encryptedNode.result);
121+
122+
const encryptedWeb2 = await webKit.tryEncrypt(data, webSecretKey, { outputEncoding: 'hex' });
123+
expect(encryptedWeb2.success).toBe(true);
124+
expect(encryptedWeb2.result).toBeDefined();
125+
expect(encryptedWeb2.result).not.toBe(encryptedWeb.result);
126+
127+
const encryptedObjNode = nodeKit.tryEncryptObj(largeObj, nodeSecretKey, { outputEncoding: 'base64' });
128+
expect(encryptedObjNode.success).toBe(true);
129+
expect(encryptedObjNode.result).toBeDefined();
130+
expect(matchPattern(encryptedObjNode.result as string, 'node')).toBe(true);
131+
132+
const encryptedObjWeb = await webKit.tryEncryptObj(largeObj, webSecretKey, { outputEncoding: 'base64' });
133+
expect(encryptedObjWeb.success).toBe(true);
134+
expect(encryptedObjWeb.result).toBeDefined();
135+
expect(matchPattern(encryptedObjWeb.result as string, 'web')).toBe(true);
136+
137+
expect(encryptedObjNode.result).not.toBe(encryptedObjWeb.result);
138+
139+
const decryptedObjNode = nodeKit.tryDecryptObj<typeof largeObj>(encryptedObjNode.result as string, nodeSecretKey, {
140+
inputEncoding: 'base64',
141+
});
142+
expect(decryptedObjNode.success).toBe(true);
143+
expect(decryptedObjNode.result).toEqual(largeObj);
144+
145+
const decryptedObjWeb = await webKit.tryDecryptObj<typeof largeObj>(
146+
encryptedObjWeb.result as string,
147+
webSecretKey,
148+
{ inputEncoding: 'base64' },
149+
);
150+
expect(decryptedObjWeb.success).toBe(true);
151+
expect(decryptedObjWeb.result).toEqual(largeObj);
152+
});
153+
154+
test('Encoding Test', () => {
155+
expect(nodeKit.convertEncoding(data, 'utf8', 'base64')).toBe(webKit.convertEncoding(data, 'utf8', 'base64'));
156+
expect(nodeKit.convertEncoding(data, 'utf8', 'hex')).toBe(webKit.convertEncoding(data, 'utf8', 'hex'));
157+
expect(nodeKit.convertEncoding(data, 'utf8', 'base64url')).toBe(webKit.convertEncoding(data, 'utf8', 'base64url'));
158+
expect(nodeKit.convertEncoding(data, 'utf8', 'latin1')).toBe(webKit.convertEncoding(data, 'utf8', 'latin1'));
159+
});
77160
});
78161

79162
const largeObj = {

packages/cipher-kit/src/helpers/consts.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const DIGEST_ALGORITHMS = Object.freeze({
77
} as const);
88

99
export const ENCRYPTION_ALGORITHMS = Object.freeze({
10-
aes256gcm: { name: 'aes256gcm', keyBytes: 32, ivLength: 12, node: 'aes-256-gcm', web: 'AES-GCM' },
11-
aes192gcm: { name: 'aes192gcm', keyBytes: 24, ivLength: 12, node: 'aes-192-gcm', web: 'AES-GCM' },
12-
aes128gcm: { name: 'aes128gcm', keyBytes: 16, ivLength: 12, node: 'aes-128-gcm', web: 'AES-GCM' },
10+
aes256gcm: { keyBytes: 32, ivLength: 12, node: 'aes-256-gcm', web: 'AES-GCM' },
11+
aes192gcm: { keyBytes: 24, ivLength: 12, node: 'aes-192-gcm', web: 'AES-GCM' },
12+
aes128gcm: { keyBytes: 16, ivLength: 12, node: 'aes-128-gcm', web: 'AES-GCM' },
1313
} as const);

packages/cipher-kit/src/helpers/types.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ type Brand<T> = { readonly [__brand]: T };
99
export type SecretKey<Platform extends 'web' | 'node'> = {
1010
readonly platform: Platform;
1111
readonly digest: keyof typeof DIGEST_ALGORITHMS;
12-
readonly algo: (typeof ENCRYPTION_ALGORITHMS)[keyof typeof ENCRYPTION_ALGORITHMS];
12+
readonly algorithm: keyof typeof ENCRYPTION_ALGORITHMS;
1313
readonly key: Platform extends 'web' ? webcrypto.CryptoKey : nodeCrypto.KeyObject;
1414
} & Brand<`secretKey-${Platform}`>;
1515

16+
export type CipherEncoding = Exclude<Encoding, 'utf8' | 'latin1'>;
17+
1618
/** Supported encoding formats */
1719
export type Encoding = (typeof ENCODINGS)[number];
1820

@@ -31,31 +33,31 @@ export interface CreateSecretKeyOptions {
3133

3234
export interface EncryptOptions {
3335
inputEncoding?: Encoding;
34-
outputEncoding?: Encoding;
36+
outputEncoding?: CipherEncoding;
3537
}
3638

3739
export interface DecryptOptions {
38-
inputEncoding?: Encoding;
40+
inputEncoding?: CipherEncoding;
3941
outputEncoding?: Encoding;
4042
}
4143

4244
export interface HashOptions {
4345
digest?: DigestAlgorithm;
4446
inputEncoding?: Encoding;
45-
outputEncoding?: Encoding;
47+
outputEncoding?: CipherEncoding;
4648
}
4749

4850
export interface HashPasswordOptions {
4951
digest?: DigestAlgorithm;
50-
outputEncoding?: Encoding;
52+
outputEncoding?: CipherEncoding;
5153
saltLength?: number;
5254
iterations?: number;
5355
keyLength?: number;
5456
}
5557

5658
export interface VerifyPasswordOptions {
5759
digest?: DigestAlgorithm;
58-
inputEncoding?: Encoding;
60+
inputEncoding?: CipherEncoding;
5961
iterations?: number;
6062
keyLength?: number;
6163
}

packages/cipher-kit/src/helpers/validate.ts

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import nodeCrypto from 'node:crypto';
2-
import type { EncryptionAlgorithm, SecretKey } from '~/helpers/types';
2+
import type { SecretKey } from '~/helpers/types';
33
import { DIGEST_ALGORITHMS, ENCRYPTION_ALGORITHMS } from './consts';
44

55
export function $isStr(x: unknown, min = 1): x is string {
@@ -16,66 +16,69 @@ export function $isLooseObj(x: unknown): x is Record<string, unknown> {
1616
return typeof x === 'object' && x !== null && x !== undefined;
1717
}
1818

19-
const expectedKeys = new Set(['platform', 'digest', 'algo', 'key']);
20-
const expectedAlgoKeys = new Set(['name', 'keyBytes', 'ivLength', 'node', 'web']);
19+
type InjectedSecretKey<Platform extends 'web' | 'node'> = SecretKey<Platform> & {
20+
readonly injected: (typeof ENCRYPTION_ALGORITHMS)[keyof typeof ENCRYPTION_ALGORITHMS];
21+
};
2122

22-
export function isSecretKey<Platform extends 'node' | 'web'>(x: unknown, platform: Platform): x is SecretKey<Platform> {
23-
if (!$isLooseObj(x) || (platform !== 'node' && platform !== 'web') || x.platform !== platform) return false;
24-
25-
const keys = Object.keys(x);
26-
if (keys.length !== expectedKeys.size) return false;
27-
for (const key of keys) if (!expectedKeys.has(key)) return false;
28-
for (const key of expectedKeys) if (!Object.hasOwn(x, key)) return false;
23+
const expectedKeys = new Set(['platform', 'digest', 'algorithm', 'key']);
2924

30-
if (typeof x.digest !== 'string' || !(x.digest in DIGEST_ALGORITHMS)) return false;
31-
if (!$isLooseObj(x.algo) || typeof x.algo.name !== 'string' || !(x.algo.name in ENCRYPTION_ALGORITHMS)) return false;
25+
export function $isSecretKey<Platform extends 'node' | 'web'>(
26+
x: unknown,
27+
platform: Platform,
28+
): InjectedSecretKey<Platform> | null {
29+
if (!$isLooseObj(x) || (platform !== 'node' && platform !== 'web') || x.platform !== platform) return null;
3230

33-
const algoKeys = Object.keys(x.algo);
34-
if (algoKeys.length !== expectedAlgoKeys.size) return false;
35-
for (const key of algoKeys) if (!expectedAlgoKeys.has(key)) return false;
36-
for (const key of expectedAlgoKeys) if (!Object.hasOwn(x.algo, key)) return false;
31+
const keys = Object.keys(x);
32+
if (keys.length !== expectedKeys.size) return null;
33+
for (const key of keys) if (!expectedKeys.has(key)) return null;
34+
for (const key of expectedKeys) if (!Object.hasOwn(x, key)) return null;
3735

38-
const algo = ENCRYPTION_ALGORITHMS[x.algo.name as EncryptionAlgorithm];
3936
if (
40-
x.algo.keyBytes !== algo.keyBytes ||
41-
x.algo.ivLength !== algo.ivLength ||
42-
x.algo.node !== algo.node ||
43-
x.algo.web !== algo.web
37+
typeof x.digest !== 'string' ||
38+
!(x.digest in DIGEST_ALGORITHMS) ||
39+
typeof x.algorithm !== 'string' ||
40+
!(x.algorithm in ENCRYPTION_ALGORITHMS) ||
41+
!$isLooseObj(x.key) ||
42+
x.key.type !== 'secret'
4443
) {
45-
return false;
44+
return null;
4645
}
4746

48-
if (!$isLooseObj(x.key) || x.key.type !== 'secret') return false;
47+
const algorithm = ENCRYPTION_ALGORITHMS[x.algorithm as keyof typeof ENCRYPTION_ALGORITHMS];
4948

5049
if (platform === 'node') {
5150
if (
5251
!(x.key instanceof nodeCrypto.KeyObject) ||
53-
(typeof x.key.symmetricKeySize === 'number' && x.key.symmetricKeySize !== algo.keyBytes)
52+
(typeof x.key.symmetricKeySize === 'number' && x.key.symmetricKeySize !== algorithm.keyBytes)
5453
) {
55-
return false;
54+
return null;
5655
}
57-
return true;
56+
return Object.freeze({ ...x, injected: algorithm }) as InjectedSecretKey<Platform>;
5857
}
5958

6059
if (
6160
!$isLooseObj(x.key.algorithm) ||
62-
x.key.algorithm.name !== algo.web ||
63-
(typeof x.key.algorithm.length === 'number' && x.key.algorithm.length !== algo.keyBytes * 8) ||
61+
x.key.algorithm.name !== algorithm.web ||
62+
(typeof x.key.algorithm.length === 'number' && x.key.algorithm.length !== algorithm.keyBytes * 8) ||
6463
typeof x.key.extractable !== 'boolean' ||
6564
!Array.isArray(x.key.usages) ||
6665
x.key.usages.length !== 2 ||
6766
!(x.key.usages.includes('encrypt') && x.key.usages.includes('decrypt'))
6867
) {
69-
return false;
68+
return null;
7069
}
71-
return true;
70+
return Object.freeze({ ...x, injected: algorithm }) as InjectedSecretKey<Platform>;
71+
}
72+
73+
export function isSecretKey<Platform extends 'node' | 'web'>(x: unknown, platform: Platform): x is SecretKey<Platform> {
74+
return $isSecretKey(x, platform) !== null;
7275
}
7376

7477
/** Regular expressions for encrypted data patterns */
7578
export const ENCRYPTED_REGEX = Object.freeze({
76-
general: /^(?:[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?\.)$/,
77-
node: /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.$/,
78-
web: /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.$/,
79+
node: /^([^.]+)\.([^.]+)\.([^.]+)\.$/,
80+
web: /^([^.]+)\.([^.]+)\.$/,
81+
general: /^([^.]+)\.([^.]+)(?:\.([^.]+))?\.$/,
7982
});
8083

8184
/** Checks if the input string matches the specified encrypted data pattern. */

packages/cipher-kit/src/node/kit.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
$encryptObj,
2020
$generateUuid,
2121
$hash,
22+
$hashObj,
2223
$hashPassword,
2324
$verifyPassword,
2425
} from './node-encrypt';
@@ -227,6 +228,34 @@ export function hash(data: string, options: HashOptions = {}): string {
227228
return result;
228229
}
229230

231+
/**
232+
* Hashes the input object by first serializing it to a JSON string using stable key ordering,
233+
* then hashing the string using SHA-256 and returning the hash in base64url format.
234+
*
235+
* @param data - The input object to hash.
236+
* @returns A Result containing a string representing the SHA-256 hash of the serialized object in base64url format or an error.
237+
* */
238+
export function tryHashObj<T extends object = Record<string, unknown>>(
239+
data: T,
240+
options: HashOptions = {},
241+
): Result<string> {
242+
return $hashObj(data, options);
243+
}
244+
245+
/**
246+
* Hashes the input object by first serializing it to a JSON string using stable key ordering,
247+
* then hashing the string using SHA-256 and returning the hash in base64url format.
248+
*
249+
* @param data - The input object to hash.
250+
* @returns A string representing the SHA-256 hash of the serialized object in base64url format.
251+
* @throws {Error} If the input data is invalid or hashing fails.
252+
*/
253+
export function hashObj<T extends object = Record<string, unknown>>(data: T, options: HashOptions = {}): string {
254+
const { result, error } = $hashObj(data, options);
255+
if (error) throw new Error($fmtResultErr(error));
256+
return result;
257+
}
258+
230259
/**
231260
* Hashes a password using PBKDF2 with SHA-512.
232261
*

packages/cipher-kit/src/node/node-encrypt.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
SecretKey,
1313
VerifyPasswordOptions,
1414
} from '~/helpers/types';
15-
import { $isStr, isSecretKey, matchPattern } from '~/helpers/validate';
15+
import { $isSecretKey, $isStr, matchPattern } from '~/helpers/validate';
1616
import { $convertBytesToStr, $convertStrToBytes } from './node-encode';
1717

1818
export function $generateUuid(): Result<string> {
@@ -78,7 +78,7 @@ export function $createSecretKey(
7878
const secretKey = Object.freeze({
7979
platform: 'node',
8080
digest: digest,
81-
algo: encryptAlgo,
81+
algorithm: algorithm,
8282
key: key,
8383
}) as SecretKey<'node'>;
8484

@@ -105,7 +105,8 @@ export function $encrypt(data: string, secretKey: SecretKey<'node'>, options: En
105105
});
106106
}
107107

108-
if (!isSecretKey(secretKey, 'node')) {
108+
const injectedKey = $isSecretKey(secretKey, 'node');
109+
if (!injectedKey) {
109110
return $err({
110111
msg: 'Crypto NodeJS API - Encryption: Invalid Secret Key',
111112
desc: 'Expected a Node SecretKey',
@@ -116,8 +117,8 @@ export function $encrypt(data: string, secretKey: SecretKey<'node'>, options: En
116117
if (error) return $err(error);
117118

118119
try {
119-
const iv = nodeCrypto.randomBytes(secretKey.algo.ivLength);
120-
const cipher = nodeCrypto.createCipheriv(secretKey.algo.node, secretKey.key, iv);
120+
const iv = nodeCrypto.randomBytes(injectedKey.injected.ivLength);
121+
const cipher = nodeCrypto.createCipheriv(injectedKey.injected.node, injectedKey.key, iv);
121122
const encrypted = Buffer.concat([cipher.update(result), cipher.final()]);
122123
const tag = cipher.getAuthTag();
123124

@@ -167,7 +168,8 @@ export function $decrypt(
167168
});
168169
}
169170

170-
if (!isSecretKey(secretKey, 'node')) {
171+
const injectedKey = $isSecretKey(secretKey, 'node');
172+
if (!injectedKey) {
171173
return $err({
172174
msg: 'Crypto NodeJS API - Decryption: Invalid Secret Key',
173175
desc: 'Expected a Node SecretKey',
@@ -186,7 +188,7 @@ export function $decrypt(
186188
}
187189

188190
try {
189-
const decipher = nodeCrypto.createDecipheriv(secretKey.algo.node, secretKey.key, ivBytes.result);
191+
const decipher = nodeCrypto.createDecipheriv(injectedKey.injected.node, injectedKey.key, ivBytes.result);
190192
decipher.setAuthTag(tagBytes.result);
191193
const decrypted = Buffer.concat([decipher.update(cipherBytes.result), decipher.final()]);
192194

@@ -252,6 +254,15 @@ export function $hash(data: string, options: HashOptions = {}): Result<string> {
252254
}
253255
}
254256

257+
export function $hashObj<T extends object = Record<string, unknown>>(
258+
data: T,
259+
options: HashOptions = {},
260+
): Result<string> {
261+
const { result, error } = $stringifyObj(data);
262+
if (error) return $err(error);
263+
return $hash(result, options);
264+
}
265+
255266
export function $hashPassword(
256267
password: string,
257268
options: HashPasswordOptions = {},

0 commit comments

Comments
 (0)