@nivinjoseph/n-sec 6.0.3 → 7.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/.yarn/releases/yarn-4.14.1.cjs +940 -0
- package/.yarnrc.yml +6 -1
- package/dist/api-security/claim.js +2 -0
- package/dist/api-security/claim.js.map +1 -1
- package/dist/api-security/claims-identity.js +1 -0
- package/dist/api-security/claims-identity.js.map +1 -1
- package/dist/api-security/expired-token-exception.js +1 -0
- package/dist/api-security/expired-token-exception.js.map +1 -1
- package/dist/api-security/invalid-token-exception.js +2 -0
- package/dist/api-security/invalid-token-exception.js.map +1 -1
- package/dist/api-security/json-web-token.d.ts +1 -1
- package/dist/api-security/json-web-token.d.ts.map +1 -1
- package/dist/api-security/json-web-token.js +35 -10
- package/dist/api-security/json-web-token.js.map +1 -1
- package/dist/api-security/security-token.js +2 -0
- package/dist/api-security/security-token.js.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/bin.js.map +1 -1
- package/dist/crypto/hash.d.ts +40 -0
- package/dist/crypto/hash.d.ts.map +1 -1
- package/dist/crypto/hash.js +60 -1
- package/dist/crypto/hash.js.map +1 -1
- package/dist/crypto/symmetric-encryption.d.ts +51 -3
- package/dist/crypto/symmetric-encryption.d.ts.map +1 -1
- package/dist/crypto/symmetric-encryption.js +90 -44
- package/dist/crypto/symmetric-encryption.js.map +1 -1
- package/dist/tsconfig.json +1 -0
- package/eslint.config.js +5 -5
- package/package.json +15 -15
- package/src/api-security/json-web-token.ts +37 -12
- package/src/bin.ts +1 -1
- package/src/crypto/hash.ts +66 -1
- package/src/crypto/symmetric-encryption.ts +98 -55
- package/test/hash.test.ts +89 -0
- package/test/hmac.test.ts +12 -12
- package/test/json-web-token.test.ts +76 -10
- package/test/symmetric-encryption.test.ts +51 -12
- package/tsconfig.json +5 -2
- package/.yarn/releases/yarn-4.0.2.cjs +0 -893
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"symmetric-encryption.js","sourceRoot":"","sources":["../../src/crypto/symmetric-encryption.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,0BAA0B,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAGxD,SAAS;AACT,MAAM,OAAO,mBAAmB;IAE5B,gBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"symmetric-encryption.js","sourceRoot":"","sources":["../../src/crypto/symmetric-encryption.ts"],"names":[],"mappings":"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"}
|
package/dist/tsconfig.json
CHANGED
package/eslint.config.js
CHANGED
|
@@ -11,7 +11,7 @@ export default defineConfig(
|
|
|
11
11
|
tsEslint.configs.recommended,
|
|
12
12
|
importPlugin.flatConfigs.recommended,
|
|
13
13
|
{
|
|
14
|
-
ignores: ["dist/**", "node_modules/**", "**/*.js", "**/*.map", "
|
|
14
|
+
ignores: ["dist/**", "node_modules/**", "**/*.js", "**/*.map", "**/*.d.ts"]
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
files: ["**/*.ts"],
|
|
@@ -50,9 +50,9 @@ export default defineConfig(
|
|
|
50
50
|
files: ["**/*.ts"],
|
|
51
51
|
languageOptions: {
|
|
52
52
|
parserOptions: {
|
|
53
|
-
project: [
|
|
54
|
-
|
|
55
|
-
],
|
|
53
|
+
// project: [
|
|
54
|
+
// "./tsconfig.json"
|
|
55
|
+
// ],
|
|
56
56
|
tsconfigRootDir: import.meta.dirname,
|
|
57
57
|
projectService: true
|
|
58
58
|
}
|
|
@@ -155,7 +155,7 @@ export default defineConfig(
|
|
|
155
155
|
],
|
|
156
156
|
"default-param-last": "off",
|
|
157
157
|
"@typescript-eslint/default-param-last": "error",
|
|
158
|
-
"@typescript-eslint/explicit-function-return-type": "error",
|
|
158
|
+
"@typescript-eslint/explicit-function-return-type": ["error", { "allowExpressions": true }],
|
|
159
159
|
"@typescript-eslint/explicit-member-accessibility": "error",
|
|
160
160
|
"@typescript-eslint/explicit-module-boundary-types": "error",
|
|
161
161
|
"func-call-spacing": "off",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nivinjoseph/n-sec",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.1",
|
|
4
4
|
"description": "Security library",
|
|
5
|
-
"packageManager": "yarn@4.
|
|
5
|
+
"packageManager": "yarn@4.14.1",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"ts-build": "yarn ts-compile && yarn ts-lint",
|
|
14
14
|
"ts-build-dist": "yarn ts-build && tsc -p ./dist",
|
|
15
15
|
"test": "yarn ts-build && node --test --enable-source-maps ./test/**/*.test.js",
|
|
16
|
-
"publish-package": "yarn ts-build-dist && git add . && git commit -m 'preparing to publish new version' && git push && npm publish --access=public"
|
|
16
|
+
"publish-package": "yarn ts-build-dist && git add . && git commit -m 'preparing to publish new version' && yarn version patch && git add . && git commit -m 'new version' && git push && npm publish --access=public"
|
|
17
17
|
},
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
@@ -30,20 +30,20 @@
|
|
|
30
30
|
},
|
|
31
31
|
"homepage": "https://github.com/nivinjoseph/n-sec#readme",
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@eslint/js": "
|
|
34
|
-
"@stylistic/eslint-plugin": "
|
|
35
|
-
"@types/node": "
|
|
36
|
-
"eslint": "
|
|
37
|
-
"eslint-import-resolver-typescript": "
|
|
38
|
-
"eslint-plugin-import": "
|
|
39
|
-
"typescript": "
|
|
40
|
-
"typescript-eslint": "
|
|
33
|
+
"@eslint/js": "10.0.1",
|
|
34
|
+
"@stylistic/eslint-plugin": "5.10.0",
|
|
35
|
+
"@types/node": "24.10.0",
|
|
36
|
+
"eslint": "10.2.1",
|
|
37
|
+
"eslint-import-resolver-typescript": "4.4.4",
|
|
38
|
+
"eslint-plugin-import": "2.32.0",
|
|
39
|
+
"typescript": "6.0.3",
|
|
40
|
+
"typescript-eslint": "8.59.0"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@nivinjoseph/n-defensive": "
|
|
44
|
-
"@nivinjoseph/n-exception": "
|
|
45
|
-
"@nivinjoseph/n-ext": "
|
|
46
|
-
"@nivinjoseph/n-util": "
|
|
43
|
+
"@nivinjoseph/n-defensive": "2.0.3",
|
|
44
|
+
"@nivinjoseph/n-exception": "2.0.3",
|
|
45
|
+
"@nivinjoseph/n-ext": "2.0.5",
|
|
46
|
+
"@nivinjoseph/n-util": "4.0.1"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
|
49
49
|
"node": ">=24.10"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { given } from "@nivinjoseph/n-defensive";
|
|
2
2
|
import { InvalidOperationException } from "@nivinjoseph/n-exception";
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
4
|
import { Hmac } from "./../crypto/hmac.js";
|
|
4
5
|
import { AlgType } from "./alg-type.js";
|
|
5
6
|
import { Claim } from "./claim.js";
|
|
@@ -14,7 +15,7 @@ export class JsonWebToken
|
|
|
14
15
|
private readonly _issuer: string;
|
|
15
16
|
private readonly _algType: AlgType;
|
|
16
17
|
private readonly _key: string;
|
|
17
|
-
private readonly
|
|
18
|
+
private readonly _isFullKey: boolean;
|
|
18
19
|
private readonly _expiry: number;
|
|
19
20
|
private readonly _claims: Array<Claim>;
|
|
20
21
|
|
|
@@ -22,7 +23,7 @@ export class JsonWebToken
|
|
|
22
23
|
public get issuer(): string { return this._issuer; }
|
|
23
24
|
public get algType(): AlgType { return this._algType; }
|
|
24
25
|
public get key(): string { return this._key; }
|
|
25
|
-
public get canGenerateToken(): boolean { return this.
|
|
26
|
+
public get canGenerateToken(): boolean { return this._isFullKey; }
|
|
26
27
|
public get expiry(): number { return this._expiry; }
|
|
27
28
|
public get isExpired(): boolean { return this._expiry <= Date.now(); }
|
|
28
29
|
public get claims(): ReadonlyArray<Claim> { return this._claims; }
|
|
@@ -42,7 +43,7 @@ export class JsonWebToken
|
|
|
42
43
|
this._issuer = issuer.trim();
|
|
43
44
|
this._algType = algType;
|
|
44
45
|
this._key = key.trim();
|
|
45
|
-
this.
|
|
46
|
+
this._isFullKey = isFullKey;
|
|
46
47
|
this._expiry = expiry;
|
|
47
48
|
this._claims = [...claims];
|
|
48
49
|
}
|
|
@@ -72,8 +73,26 @@ export class JsonWebToken
|
|
|
72
73
|
const bodyString = tokenSplitted[1];
|
|
73
74
|
const signature = tokenSplitted[2];
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
let parsedHeader: unknown;
|
|
77
|
+
let parsedBody: unknown;
|
|
78
|
+
try
|
|
79
|
+
{
|
|
80
|
+
parsedHeader = JsonWebToken._toObject(headerString);
|
|
81
|
+
parsedBody = JsonWebToken._toObject(bodyString);
|
|
82
|
+
}
|
|
83
|
+
catch
|
|
84
|
+
{
|
|
85
|
+
throw new InvalidTokenException(token, "header or body could not be parsed");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (parsedHeader == null || typeof parsedHeader !== "object" || Array.isArray(parsedHeader))
|
|
89
|
+
throw new InvalidTokenException(token, "header is not an object");
|
|
90
|
+
|
|
91
|
+
if (parsedBody == null || typeof parsedBody !== "object" || Array.isArray(parsedBody))
|
|
92
|
+
throw new InvalidTokenException(token, "body is not an object");
|
|
93
|
+
|
|
94
|
+
const header = parsedHeader as Header;
|
|
95
|
+
const body = parsedBody as Record<string, unknown>;
|
|
77
96
|
|
|
78
97
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
79
98
|
if (header.iss === undefined || header.iss === null)
|
|
@@ -116,26 +135,32 @@ export class JsonWebToken
|
|
|
116
135
|
// }
|
|
117
136
|
|
|
118
137
|
const computedSignature = Hmac.create(key, headerString + "." + bodyString);
|
|
119
|
-
|
|
138
|
+
const expected = Buffer.from(computedSignature, "utf8");
|
|
139
|
+
const provided = Buffer.from(signature, "utf8");
|
|
140
|
+
if (expected.length !== provided.length || !timingSafeEqual(expected, provided))
|
|
120
141
|
throw new InvalidTokenException(token, "signature could not be verified");
|
|
121
142
|
|
|
143
|
+
const invalidBodyKeys = new Set(["__proto__", "constructor", "prototype"]);
|
|
122
144
|
const claims = new Array<Claim>();
|
|
123
|
-
for (const
|
|
124
|
-
|
|
145
|
+
for (const [type, value] of Object.entries(body))
|
|
146
|
+
{
|
|
147
|
+
if (invalidBodyKeys.has(type))
|
|
148
|
+
throw new InvalidTokenException(token, `body contains invalid key '${type}'`);
|
|
149
|
+
claims.push(new Claim(type, value));
|
|
150
|
+
}
|
|
125
151
|
|
|
126
152
|
return new JsonWebToken(issuer, algType, key, false, header.exp, claims);
|
|
127
153
|
}
|
|
128
154
|
|
|
129
|
-
private static _toObject(hex: string):
|
|
155
|
+
private static _toObject(hex: string): unknown
|
|
130
156
|
{
|
|
131
157
|
const json = Buffer.from(hex.toLowerCase(), "hex").toString("utf8");
|
|
132
|
-
|
|
133
|
-
return obj;
|
|
158
|
+
return JSON.parse(json);
|
|
134
159
|
}
|
|
135
160
|
|
|
136
161
|
public generateToken(): string
|
|
137
162
|
{
|
|
138
|
-
if (!this.
|
|
163
|
+
if (!this._isFullKey)
|
|
139
164
|
throw new InvalidOperationException("generating token using an instance created from token");
|
|
140
165
|
|
|
141
166
|
const header: Header = {
|
package/src/bin.ts
CHANGED
|
@@ -20,7 +20,7 @@ async function executeCommand(command: SupportedCommands): Promise<void>
|
|
|
20
20
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
21
21
|
case SupportedCommands.generateSymmetricKey:
|
|
22
22
|
{
|
|
23
|
-
const key =
|
|
23
|
+
const key = SymmetricEncryption.generateKey();
|
|
24
24
|
console.log("SYMMETRIC KEY => ", key);
|
|
25
25
|
break;
|
|
26
26
|
}
|
package/src/crypto/hash.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { given } from "@nivinjoseph/n-defensive";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, scryptSync, timingSafeEqual } from "node:crypto";
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
// public
|
|
@@ -18,6 +18,16 @@ export class Hash
|
|
|
18
18
|
return hash.digest("hex").toUpperCase();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* @deprecated Unsafe for password storage. Uses a single round of
|
|
23
|
+
* SHA-512, which a GPU can compute at billions of hashes/second —
|
|
24
|
+
* leaked hashes can be brute-forced rapidly against any wordlist.
|
|
25
|
+
* Additionally trims leading/trailing whitespace from both `value`
|
|
26
|
+
* and `salt`, so inputs differing only in whitespace collide.
|
|
27
|
+
*
|
|
28
|
+
* For password hashing, use {@link createForPassword} +
|
|
29
|
+
* {@link verifyPassword} instead.
|
|
30
|
+
*/
|
|
21
31
|
public static createUsingSalt(value: string, salt: string): string
|
|
22
32
|
{
|
|
23
33
|
given(value, "value").ensureHasValue().ensureIsString();
|
|
@@ -43,4 +53,59 @@ export class Hash
|
|
|
43
53
|
|
|
44
54
|
return Hash.create(saltedValue);
|
|
45
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Derives a 64-byte hash from `password` and `salt` using scrypt with
|
|
59
|
+
* parameters N=2^15, r=8, p=1 (RFC 7914 recommended defaults). Suitable
|
|
60
|
+
* for password storage: slow and memory-hard, so leaked hashes resist
|
|
61
|
+
* GPU brute-force attacks far better than a plain SHA-512.
|
|
62
|
+
*
|
|
63
|
+
* Inputs are NOT trimmed — the exact bytes of `password` and `salt` are
|
|
64
|
+
* hashed. Two passwords differing only in whitespace produce different
|
|
65
|
+
* outputs.
|
|
66
|
+
*
|
|
67
|
+
* @param password - The password to hash. Hashed as UTF-8.
|
|
68
|
+
* @param salt - A per-user random value (recommended: ≥16 random bytes,
|
|
69
|
+
* e.g. `randomBytes(16).toString("hex")`). Must be stored alongside
|
|
70
|
+
* the hash so the same value can be passed on verification.
|
|
71
|
+
* @returns A 128-character uppercase hex string (64 bytes).
|
|
72
|
+
*/
|
|
73
|
+
public static createForPassword(password: string, salt: string): string
|
|
74
|
+
{
|
|
75
|
+
given(password, "password").ensureHasValue().ensureIsString();
|
|
76
|
+
given(salt, "salt").ensureHasValue().ensureIsString();
|
|
77
|
+
|
|
78
|
+
const derived = scryptSync(password, salt, 64, {
|
|
79
|
+
N: 1 << 15,
|
|
80
|
+
r: 8,
|
|
81
|
+
p: 1,
|
|
82
|
+
maxmem: 64 * 1024 * 1024
|
|
83
|
+
});
|
|
84
|
+
return derived.toString("hex").toUpperCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Verifies a password against a hash previously produced by
|
|
89
|
+
* {@link createForPassword}. The comparison is constant-time, so
|
|
90
|
+
* timing cannot be used to learn how many leading bytes matched.
|
|
91
|
+
*
|
|
92
|
+
* @param password - The candidate password to verify. Hashed as UTF-8.
|
|
93
|
+
* @param salt - The same salt that was passed to
|
|
94
|
+
* {@link createForPassword} when the stored hash was created.
|
|
95
|
+
* @param expectedHash - The previously-stored hash (hex string, any case).
|
|
96
|
+
* @returns `true` if the candidate matches; `false` if it doesn't,
|
|
97
|
+
* if `expectedHash` is not valid hex, or if lengths differ.
|
|
98
|
+
*/
|
|
99
|
+
public static verifyPassword(password: string, salt: string, expectedHash: string): boolean
|
|
100
|
+
{
|
|
101
|
+
given(password, "password").ensureHasValue().ensureIsString();
|
|
102
|
+
given(salt, "salt").ensureHasValue().ensureIsString();
|
|
103
|
+
given(expectedHash, "expectedHash").ensureHasValue().ensureIsString();
|
|
104
|
+
|
|
105
|
+
const computed = Buffer.from(Hash.createForPassword(password, salt), "hex");
|
|
106
|
+
const expected = Buffer.from(expectedHash, "hex");
|
|
107
|
+
if (computed.length !== expected.length)
|
|
108
|
+
return false;
|
|
109
|
+
return timingSafeEqual(computed, expected);
|
|
110
|
+
}
|
|
46
111
|
}
|
|
@@ -9,74 +9,117 @@ export class SymmetricEncryption
|
|
|
9
9
|
private constructor() { }
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Generates a cryptographically random 256-bit key suitable for use with
|
|
14
|
+
* {@link encrypt} and {@link decrypt}.
|
|
15
|
+
*
|
|
16
|
+
* @returns A 64-character uppercase hex string (32 bytes) sourced from the
|
|
17
|
+
* platform CSPRNG via Node's `crypto.randomBytes`.
|
|
18
|
+
*/
|
|
19
|
+
public static generateKey(): string
|
|
13
20
|
{
|
|
14
|
-
return
|
|
15
|
-
{
|
|
16
|
-
randomBytes(32, (err, buf) =>
|
|
17
|
-
{
|
|
18
|
-
if (err)
|
|
19
|
-
{
|
|
20
|
-
reject(err);
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
resolve(buf.toString("hex").toUpperCase());
|
|
25
|
-
});
|
|
26
|
-
});
|
|
21
|
+
return randomBytes(32).toString("hex").toUpperCase();
|
|
27
22
|
}
|
|
28
23
|
|
|
29
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Encrypts a UTF-8 string using AES-256-GCM with a fresh random 96-bit IV.
|
|
26
|
+
*
|
|
27
|
+
* The result is an authenticated ciphertext: any bit-flip, truncation, or
|
|
28
|
+
* substitution will cause {@link decrypt} to throw a {@link CryptoException}.
|
|
29
|
+
*
|
|
30
|
+
* @param key - A 64-character hex string (32 bytes) as produced by
|
|
31
|
+
* {@link generateKey}. Case-insensitive.
|
|
32
|
+
* @param value - The plaintext to encrypt. Interpreted as UTF-8.
|
|
33
|
+
* @param aad - Optional Additional Authenticated Data. When supplied, its
|
|
34
|
+
* UTF-8 bytes are bound into the auth tag but not stored in the output;
|
|
35
|
+
* the exact same `aad` must be passed to {@link decrypt}, otherwise
|
|
36
|
+
* decryption will fail. Use this to bind a ciphertext to its context
|
|
37
|
+
* (e.g. `` `user:${userId}` ``) so a valid ciphertext from one record
|
|
38
|
+
* cannot be substituted into another.
|
|
39
|
+
* @returns An uppercase hex string in the form `IV.CIPHERTEXT.TAG`, where
|
|
40
|
+
* `IV` is 24 hex chars (12 bytes), `TAG` is 32 hex chars (16 bytes), and
|
|
41
|
+
* `CIPHERTEXT` is the encrypted payload.
|
|
42
|
+
* @throws {CryptoException} If `key` is not exactly 64 hex characters.
|
|
43
|
+
*/
|
|
44
|
+
public static encrypt(key: string, value: string, aad?: string): string
|
|
30
45
|
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
given(
|
|
46
|
+
given(key, "key").ensureHasValue().ensureIsString();
|
|
47
|
+
given(value, "value").ensureHasValue().ensureIsString();
|
|
48
|
+
if (aad != null)
|
|
49
|
+
given(aad, "aad").ensureIsString();
|
|
35
50
|
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
const keyBuf = SymmetricEncryption._decodeKey(key);
|
|
52
|
+
const iv = randomBytes(12);
|
|
38
53
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
54
|
+
const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
|
|
55
|
+
if (aad != null && aad.length > 0)
|
|
56
|
+
cipher.setAAD(Buffer.from(aad, "utf8"));
|
|
57
|
+
const ct = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
58
|
+
const tag = cipher.getAuthTag();
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const iv = buf;
|
|
50
|
-
const cipher = createCipheriv("AES-256-CBC", Buffer.from(key, "hex"), iv);
|
|
51
|
-
let encrypted = cipher.update(value, "utf8", "hex");
|
|
52
|
-
encrypted += cipher.final("hex");
|
|
53
|
-
const cipherText = `${encrypted}.${iv.toString("hex")}`;
|
|
54
|
-
resolve(cipherText.toUpperCase());
|
|
55
|
-
}
|
|
56
|
-
catch (error)
|
|
57
|
-
{
|
|
58
|
-
reject(error);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
});
|
|
60
|
+
return [iv.toString("hex"), ct.toString("hex"), tag.toString("hex")]
|
|
61
|
+
.join(".").toUpperCase();
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Decrypts a ciphertext produced by {@link encrypt} and returns the
|
|
66
|
+
* original UTF-8 plaintext. Integrity is verified via the GCM auth tag
|
|
67
|
+
* before the plaintext is returned — if verification fails, nothing is
|
|
68
|
+
* returned.
|
|
69
|
+
*
|
|
70
|
+
* @param key - The same 64-character hex key that was used to encrypt.
|
|
71
|
+
* Case-insensitive.
|
|
72
|
+
* @param value - The ciphertext string in `IV.CIPHERTEXT.TAG` form as
|
|
73
|
+
* produced by {@link encrypt}.
|
|
74
|
+
* @param aad - The same Additional Authenticated Data that was supplied
|
|
75
|
+
* to {@link encrypt}, or omitted if none was supplied. Any mismatch
|
|
76
|
+
* (wrong value, supplied here but not at encrypt, or vice versa) will
|
|
77
|
+
* cause the auth tag check to fail.
|
|
78
|
+
* @returns The original plaintext decoded as UTF-8.
|
|
79
|
+
* @throws {CryptoException} If `key` is not exactly 64 hex characters,
|
|
80
|
+
* if `value` is not in the expected `IV.CIPHERTEXT.TAG` format with
|
|
81
|
+
* correct component lengths, or if the auth tag verification fails
|
|
82
|
+
* (wrong key, tampered ciphertext, or mismatched `aad`).
|
|
83
|
+
*/
|
|
84
|
+
public static decrypt(key: string, value: string, aad?: string): string
|
|
65
85
|
{
|
|
66
86
|
given(key, "key").ensureHasValue().ensureIsString();
|
|
67
87
|
given(value, "value").ensureHasValue().ensureIsString();
|
|
88
|
+
if (aad != null)
|
|
89
|
+
given(aad, "aad").ensureIsString();
|
|
90
|
+
|
|
91
|
+
const keyBuf = SymmetricEncryption._decodeKey(key);
|
|
68
92
|
|
|
69
|
-
|
|
70
|
-
|
|
93
|
+
const parts = value.split(".");
|
|
94
|
+
if (parts.length !== 3)
|
|
95
|
+
throw new CryptoException("Malformed ciphertext.");
|
|
71
96
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
97
|
+
const iv = Buffer.from(parts[0], "hex");
|
|
98
|
+
const ct = Buffer.from(parts[1], "hex");
|
|
99
|
+
const tag = Buffer.from(parts[2], "hex");
|
|
75
100
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
101
|
+
if (iv.length !== 12 || ct.length === 0 || tag.length !== 16)
|
|
102
|
+
throw new CryptoException("Malformed ciphertext.");
|
|
103
|
+
|
|
104
|
+
try
|
|
105
|
+
{
|
|
106
|
+
const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
|
|
107
|
+
if (aad != null && aad.length > 0)
|
|
108
|
+
decipher.setAAD(Buffer.from(aad, "utf8"));
|
|
109
|
+
decipher.setAuthTag(tag);
|
|
110
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
111
|
+
}
|
|
112
|
+
catch
|
|
113
|
+
{
|
|
114
|
+
throw new CryptoException("Integrity check failed.");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private static _decodeKey(key: string): Buffer
|
|
119
|
+
{
|
|
120
|
+
const buf = Buffer.from(key, "hex");
|
|
121
|
+
if (buf.length !== 32 || buf.toString("hex").toUpperCase() !== key.toUpperCase())
|
|
122
|
+
throw new CryptoException("Key must be 64 hex characters (32 bytes).");
|
|
123
|
+
return buf;
|
|
81
124
|
}
|
|
82
|
-
}
|
|
125
|
+
}
|
package/test/hash.test.ts
CHANGED
|
@@ -164,6 +164,95 @@ await describe("Hash", async () =>
|
|
|
164
164
|
|
|
165
165
|
Hash.createUsingSalt(password, salt);
|
|
166
166
|
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await describe("createForPassword", async () =>
|
|
170
|
+
{
|
|
171
|
+
await test("must return a 128-character uppercase hex string", () =>
|
|
172
|
+
{
|
|
173
|
+
const hash = Hash.createForPassword("password", "some-salt");
|
|
174
|
+
assert.match(hash, /^[0-9A-F]{128}$/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await test("same password and salt must produce the same output", () =>
|
|
178
|
+
{
|
|
179
|
+
const hash1 = Hash.createForPassword("password", "some-salt");
|
|
180
|
+
const hash2 = Hash.createForPassword("password", "some-salt");
|
|
181
|
+
assert.strictEqual(hash1, hash2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await test("different passwords with the same salt must produce different outputs", () =>
|
|
185
|
+
{
|
|
186
|
+
const hash1 = Hash.createForPassword("password1", "some-salt");
|
|
187
|
+
const hash2 = Hash.createForPassword("password2", "some-salt");
|
|
188
|
+
assert.notStrictEqual(hash1, hash2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await test("same password with different salts must produce different outputs", () =>
|
|
192
|
+
{
|
|
193
|
+
const hash1 = Hash.createForPassword("password", "salt-1");
|
|
194
|
+
const hash2 = Hash.createForPassword("password", "salt-2");
|
|
195
|
+
assert.notStrictEqual(hash1, hash2);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await test("passwords differing only in trailing whitespace must produce different outputs", () =>
|
|
199
|
+
{
|
|
200
|
+
const hash1 = Hash.createForPassword("password", "some-salt");
|
|
201
|
+
const hash2 = Hash.createForPassword("password ", "some-salt");
|
|
202
|
+
assert.notStrictEqual(hash1, hash2);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await test("passwords differing only in leading whitespace must produce different outputs", () =>
|
|
206
|
+
{
|
|
207
|
+
const hash1 = Hash.createForPassword("password", "some-salt");
|
|
208
|
+
const hash2 = Hash.createForPassword(" password", "some-salt");
|
|
209
|
+
assert.notStrictEqual(hash1, hash2);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await describe("verifyPassword", async () =>
|
|
214
|
+
{
|
|
215
|
+
await test("must return true for the same password and salt that produced the hash", () =>
|
|
216
|
+
{
|
|
217
|
+
const hash = Hash.createForPassword("password", "some-salt");
|
|
218
|
+
assert.strictEqual(Hash.verifyPassword("password", "some-salt", hash), true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await test("must accept the hash in lowercase as well as uppercase", () =>
|
|
222
|
+
{
|
|
223
|
+
const hash = Hash.createForPassword("password", "some-salt");
|
|
224
|
+
assert.strictEqual(Hash.verifyPassword("password", "some-salt", hash.toLowerCase()), true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test("must return false for a wrong password", () =>
|
|
228
|
+
{
|
|
229
|
+
const hash = Hash.createForPassword("password", "some-salt");
|
|
230
|
+
assert.strictEqual(Hash.verifyPassword("wrong-password", "some-salt", hash), false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test("must return false for a wrong salt", () =>
|
|
234
|
+
{
|
|
235
|
+
const hash = Hash.createForPassword("password", "some-salt");
|
|
236
|
+
assert.strictEqual(Hash.verifyPassword("password", "different-salt", hash), false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await test("must return false for a tampered hash", () =>
|
|
240
|
+
{
|
|
241
|
+
const hash = Hash.createForPassword("password", "some-salt");
|
|
242
|
+
// eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with
|
|
243
|
+
const tampered = (hash[0] === "A" ? "B" : "A") + hash.slice(1);
|
|
244
|
+
assert.strictEqual(Hash.verifyPassword("password", "some-salt", tampered), false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await test("must return false for a hash of the wrong length", () =>
|
|
248
|
+
{
|
|
249
|
+
assert.strictEqual(Hash.verifyPassword("password", "some-salt", "ABCD"), false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await test("must return false for a non-hex hash", () =>
|
|
253
|
+
{
|
|
254
|
+
assert.strictEqual(Hash.verifyPassword("password", "some-salt", "Z".repeat(128)), false);
|
|
255
|
+
});
|
|
167
256
|
|
|
168
257
|
|
|
169
258
|
|
package/test/hmac.test.ts
CHANGED
|
@@ -8,9 +8,9 @@ await describe("Hmac", async () =>
|
|
|
8
8
|
{
|
|
9
9
|
await describe("create", async () =>
|
|
10
10
|
{
|
|
11
|
-
await test("should return string value that is not null, empty, whitespace or same as the key or input",
|
|
11
|
+
await test("should return string value that is not null, empty, whitespace or same as the key or input", () =>
|
|
12
12
|
{
|
|
13
|
-
const key =
|
|
13
|
+
const key = SymmetricEncryption.generateKey();
|
|
14
14
|
const value = "hello world";
|
|
15
15
|
const hmac = Hmac.create(key, value);
|
|
16
16
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
@@ -19,30 +19,30 @@ await describe("Hmac", async () =>
|
|
|
19
19
|
assert.notStrictEqual(hmac, value);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
await test("multiple invocations with the same key and value must return the same output",
|
|
22
|
+
await test("multiple invocations with the same key and value must return the same output", () =>
|
|
23
23
|
{
|
|
24
|
-
const key =
|
|
24
|
+
const key = SymmetricEncryption.generateKey();
|
|
25
25
|
const value = "hello world";
|
|
26
26
|
const hmac1 = Hmac.create(key, value);
|
|
27
27
|
const hmac2 = Hmac.create(key, value);
|
|
28
28
|
assert.strictEqual(hmac1, hmac2);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
await test("multiple invocations with different keys and different values must return different outputs",
|
|
31
|
+
await test("multiple invocations with different keys and different values must return different outputs", () =>
|
|
32
32
|
{
|
|
33
|
-
const key1 =
|
|
33
|
+
const key1 = SymmetricEncryption.generateKey();
|
|
34
34
|
const value1 = "hello world";
|
|
35
35
|
const hmac1 = Hmac.create(key1, value1);
|
|
36
36
|
|
|
37
|
-
const key2 =
|
|
37
|
+
const key2 = SymmetricEncryption.generateKey();
|
|
38
38
|
const value2 = "goodbye world";
|
|
39
39
|
const hmac2 = Hmac.create(key2, value2);
|
|
40
40
|
assert.notStrictEqual(hmac1, hmac2);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
await test("multiple invocations with the same key and different values must return different outputs",
|
|
43
|
+
await test("multiple invocations with the same key and different values must return different outputs", () =>
|
|
44
44
|
{
|
|
45
|
-
const key =
|
|
45
|
+
const key = SymmetricEncryption.generateKey();
|
|
46
46
|
const value1 = "hello world";
|
|
47
47
|
const value2 = "goodbye world";
|
|
48
48
|
const hmac1 = Hmac.create(key, value1);
|
|
@@ -50,10 +50,10 @@ await describe("Hmac", async () =>
|
|
|
50
50
|
assert.notStrictEqual(hmac1, hmac2);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
await test("multiple invocations with different keys and the same value must return different outputs",
|
|
53
|
+
await test("multiple invocations with different keys and the same value must return different outputs", () =>
|
|
54
54
|
{
|
|
55
|
-
const key1 =
|
|
56
|
-
const key2 =
|
|
55
|
+
const key1 = SymmetricEncryption.generateKey();
|
|
56
|
+
const key2 = SymmetricEncryption.generateKey();
|
|
57
57
|
const value = "hello world";
|
|
58
58
|
const hmac1 = Hmac.create(key1, value);
|
|
59
59
|
const hmac2 = Hmac.create(key2, value);
|