@lakutata/security 3.0.0-beta.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/LICENSE +23 -0
- package/README.md +143 -0
- package/THIRD_PARTY_NOTICES.md +36 -0
- package/dist/cjs/exceptions/DecryptionFailedException.d.ts +13 -0
- package/dist/cjs/exceptions/DecryptionFailedException.js +20 -0
- package/dist/cjs/exceptions/InvalidSignatureException.d.ts +13 -0
- package/dist/cjs/exceptions/InvalidSignatureException.js +20 -0
- package/dist/cjs/exceptions/PasswordTooLongException.d.ts +12 -0
- package/dist/cjs/exceptions/PasswordTooLongException.js +19 -0
- package/dist/cjs/exports/Security.d.ts +19 -0
- package/dist/cjs/exports/Security.js +38 -0
- package/dist/cjs/functions/Encryption.d.ts +95 -0
- package/dist/cjs/functions/Encryption.js +208 -0
- package/dist/cjs/functions/GenerateSecret.d.ts +20 -0
- package/dist/cjs/functions/GenerateSecret.js +26 -0
- package/dist/cjs/functions/HMAC.d.ts +50 -0
- package/dist/cjs/functions/HMAC.js +58 -0
- package/dist/cjs/functions/TimingSafeEqual.d.ts +18 -0
- package/dist/cjs/functions/TimingSafeEqual.js +30 -0
- package/dist/cjs/lib/AssertKey.d.ts +5 -0
- package/dist/cjs/lib/AssertKey.js +11 -0
- package/dist/cjs/lib/bcrypt/constants.d.ts +13 -0
- package/dist/cjs/lib/bcrypt/constants.js +213 -0
- package/dist/cjs/lib/bcrypt/index.d.ts +84 -0
- package/dist/cjs/lib/bcrypt/index.js +658 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/providers/Crypt.d.ts +71 -0
- package/dist/cjs/providers/Crypt.js +88 -0
- package/dist/cjs/providers/Mac.d.ts +86 -0
- package/dist/cjs/providers/Mac.js +114 -0
- package/dist/cjs/providers/PasswordHash.d.ts +78 -0
- package/dist/cjs/providers/PasswordHash.js +146 -0
- package/dist/esm/exceptions/DecryptionFailedException.js +16 -0
- package/dist/esm/exceptions/InvalidSignatureException.js +16 -0
- package/dist/esm/exceptions/PasswordTooLongException.js +15 -0
- package/dist/esm/exports/Security.js +19 -0
- package/dist/esm/functions/Encryption.js +202 -0
- package/dist/esm/functions/GenerateSecret.js +23 -0
- package/dist/esm/functions/HMAC.js +54 -0
- package/dist/esm/functions/TimingSafeEqual.js +27 -0
- package/dist/esm/lib/AssertKey.js +8 -0
- package/dist/esm/lib/bcrypt/constants.js +202 -0
- package/dist/esm/lib/bcrypt/index.js +645 -0
- package/dist/esm/providers/Crypt.js +84 -0
- package/dist/esm/providers/Mac.js +110 -0
- package/dist/esm/providers/PasswordHash.js +109 -0
- package/dist/types/exceptions/DecryptionFailedException.d.ts +13 -0
- package/dist/types/exceptions/InvalidSignatureException.d.ts +13 -0
- package/dist/types/exceptions/PasswordTooLongException.d.ts +12 -0
- package/dist/types/exports/Security.d.ts +19 -0
- package/dist/types/functions/Encryption.d.ts +95 -0
- package/dist/types/functions/GenerateSecret.d.ts +20 -0
- package/dist/types/functions/HMAC.d.ts +50 -0
- package/dist/types/functions/TimingSafeEqual.d.ts +18 -0
- package/dist/types/lib/AssertKey.d.ts +5 -0
- package/dist/types/lib/bcrypt/constants.d.ts +13 -0
- package/dist/types/lib/bcrypt/index.d.ts +84 -0
- package/dist/types/providers/Crypt.d.ts +71 -0
- package/dist/types/providers/Mac.d.ts +86 -0
- package/dist/types/providers/PasswordHash.d.ts +78 -0
- package/package.json +45 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { DTO, Provider } from '@lakutata/core';
|
|
11
|
+
import { Configurable } from '@lakutata/core/decorator/di';
|
|
12
|
+
import { decryptByKey, encryptByKey } from '../functions/Encryption.js';
|
|
13
|
+
/**
|
|
14
|
+
* The encryption provider: encrypts data with AES-256-GCM (authenticated encryption), with key rotation (the first key
|
|
15
|
+
* encrypts, every key decrypts). Use it to keep secrets confidential at rest or through a client: tokens stored in a
|
|
16
|
+
* database, data put in a cookie. The encrypted data is also tamper-proof: changing a byte makes it fail to decrypt.
|
|
17
|
+
* It is not a password hash: a value that only needs to be checked, never read back, belongs to
|
|
18
|
+
* {@link PasswordHash}.
|
|
19
|
+
*
|
|
20
|
+
* Declare it in the `providers` of the application (`{class: Crypt, keys: [...]}`) and get it with `@Inject` or
|
|
21
|
+
* `getObject()`; it is scoped, like every provider. Each encryption derives a fresh key from the configured key and a
|
|
22
|
+
* random salt (HKDF-sha256) and uses a random IV, so encrypting the same data twice gives different outputs. The output
|
|
23
|
+
* is a base64url string (no padding) of 1 version byte, a 16-byte salt, a 12-byte IV, a 16-byte authentication tag and
|
|
24
|
+
* the ciphertext (45 bytes more than the data, before base64url), the same format as {@link encryptByKey}.
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* import {Application} from 'lakutata'
|
|
28
|
+
* import {Crypt, DecryptionFailedException, generateSecret} from 'lakutata/security'
|
|
29
|
+
*
|
|
30
|
+
* Application
|
|
31
|
+
* .run(() => ({
|
|
32
|
+
* id: 'vault.app',
|
|
33
|
+
* name: 'Vault',
|
|
34
|
+
* providers: {
|
|
35
|
+
* //The keys come from the environment: the new key first, the previous one kept to decrypt
|
|
36
|
+
* crypt: {class: Crypt, keys: [process.env.CRYPT_KEY ?? generateSecret(), process.env.CRYPT_PREVIOUS_KEY ?? generateSecret()]}
|
|
37
|
+
* }
|
|
38
|
+
* }))
|
|
39
|
+
* .onLaunched(async (app: Application): Promise<void> => {
|
|
40
|
+
* const crypt: Crypt = await app.getObject('crypt')
|
|
41
|
+
* const encrypted: string = crypt.encrypt('api-token-123')
|
|
42
|
+
* try {
|
|
43
|
+
* console.log(crypt.decryptString(encrypted))
|
|
44
|
+
* console.log(crypt.decrypt(encrypted).length)
|
|
45
|
+
* } catch (error) {
|
|
46
|
+
* if (error instanceof DecryptionFailedException) console.log('Changed, or encrypted with an unknown key')
|
|
47
|
+
* }
|
|
48
|
+
* })
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export class Crypt extends Provider {
|
|
52
|
+
/**
|
|
53
|
+
* Encrypt data with the first key.
|
|
54
|
+
* @param data The data to encrypt: a string (encrypted as its UTF-8 bytes) or bytes.
|
|
55
|
+
* @returns The encrypted data, base64url encoded without padding.
|
|
56
|
+
*/
|
|
57
|
+
encrypt(data) {
|
|
58
|
+
return encryptByKey(data, this.keys[0]);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Decrypt data encrypted by {@link Crypt.encrypt} (or {@link encryptByKey}) with any of the keys, tried in order.
|
|
62
|
+
* @param encrypted The encrypted data, base64url as {@link Crypt.encrypt} returns it.
|
|
63
|
+
* @returns The decrypted bytes.
|
|
64
|
+
* @throws {DecryptionFailedException} When no key decrypts the data, or it was changed, truncated or not produced by
|
|
65
|
+
* {@link Crypt.encrypt}.
|
|
66
|
+
*/
|
|
67
|
+
decrypt(encrypted) {
|
|
68
|
+
return decryptByKey(encrypted, this.keys);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Decrypt data encrypted by {@link Crypt.encrypt} with any of the keys, and decode it as a UTF-8 string.
|
|
72
|
+
* @param encrypted The encrypted data, base64url as {@link Crypt.encrypt} returns it.
|
|
73
|
+
* @returns The decrypted string.
|
|
74
|
+
* @throws {DecryptionFailedException} When no key decrypts the data, or it was changed, truncated or not produced by
|
|
75
|
+
* {@link Crypt.encrypt}.
|
|
76
|
+
*/
|
|
77
|
+
decryptString(encrypted) {
|
|
78
|
+
return this.decrypt(encrypted).toString('utf8');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
__decorate([
|
|
82
|
+
Configurable(DTO.Array().items(DTO.String().min(16)).min(1).required()),
|
|
83
|
+
__metadata("design:type", Array)
|
|
84
|
+
], Crypt.prototype, "keys", void 0);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { DTO, Provider } from '@lakutata/core';
|
|
11
|
+
import { Configurable } from '@lakutata/core/decorator/di';
|
|
12
|
+
import { hmac, verifyHMAC } from '../functions/HMAC.js';
|
|
13
|
+
import { InvalidSignatureException } from '../exceptions/InvalidSignatureException.js';
|
|
14
|
+
/**
|
|
15
|
+
* The message signing provider: signs data with an HMAC (SHA-256 by default) and verifies the signatures, with key
|
|
16
|
+
* rotation (the first key signs, every key verifies). Use it to detect tampering with the data that goes through a
|
|
17
|
+
* client: signed cookies, links, webhook payloads, tokens. A signature proves the data was signed by a holder of a key;
|
|
18
|
+
* it does not hide the data (use {@link Crypt}) and is not a password hash (use {@link PasswordHash}).
|
|
19
|
+
*
|
|
20
|
+
* Declare it in the `providers` of the application (`{class: Mac, keys: [...]}`) and get it with `@Inject` or
|
|
21
|
+
* `getObject()`; it is scoped, like every provider. The signatures are base64url strings without padding (43
|
|
22
|
+
* characters with SHA-256, 64 with SHA-384, 86 with SHA-512) and never expire by themselves: put a time in the signed
|
|
23
|
+
* data to limit their validity. Verification takes a constant time and tries every key, so it does not reveal which
|
|
24
|
+
* key failed.
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* import {Application} from 'lakutata'
|
|
28
|
+
* import {generateSecret, InvalidSignatureException, Mac} from 'lakutata/security'
|
|
29
|
+
*
|
|
30
|
+
* Application
|
|
31
|
+
* .run(() => ({
|
|
32
|
+
* id: 'links.app',
|
|
33
|
+
* name: 'Links',
|
|
34
|
+
* providers: {
|
|
35
|
+
* //The keys come from the environment: the new key first, the previous one kept to verify
|
|
36
|
+
* mac: {class: Mac, keys: [process.env.MAC_KEY ?? generateSecret(), process.env.MAC_PREVIOUS_KEY ?? generateSecret()]}
|
|
37
|
+
* }
|
|
38
|
+
* }))
|
|
39
|
+
* .onLaunched(async (app: Application): Promise<void> => {
|
|
40
|
+
* const mac: Mac = await app.getObject('mac')
|
|
41
|
+
* //'user:42.<signature>'
|
|
42
|
+
* const token: string = mac.signMessage('user:42')
|
|
43
|
+
* try {
|
|
44
|
+
* console.log(mac.unsignMessage(token))
|
|
45
|
+
* } catch (error) {
|
|
46
|
+
* if (error instanceof InvalidSignatureException) console.log('Tampered token')
|
|
47
|
+
* }
|
|
48
|
+
* //A detached signature, sent alongside the body
|
|
49
|
+
* const signature: string = mac.sign('{"order":42}')
|
|
50
|
+
* console.log(mac.verify('{"order":42}', signature))
|
|
51
|
+
* })
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export class Mac extends Provider {
|
|
55
|
+
/**
|
|
56
|
+
* Sign data with the first key.
|
|
57
|
+
* @param data The data to sign: a string (signed as its UTF-8 bytes) or bytes.
|
|
58
|
+
* @returns The HMAC of the data, base64url encoded without padding.
|
|
59
|
+
*/
|
|
60
|
+
sign(data) {
|
|
61
|
+
return hmac(data, this.keys[0], this.algorithm);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Check a detached signature made by {@link Mac.sign} with any of the keys, in a time that does not depend on the
|
|
65
|
+
* signature or on the key that matches.
|
|
66
|
+
* @param data The signed data, exactly as it was signed (same string or same bytes).
|
|
67
|
+
* @param signature The signature to check, base64url as {@link Mac.sign} returns it.
|
|
68
|
+
* @returns `true` when one of the keys signed the data; `false` otherwise (never throws on a malformed signature).
|
|
69
|
+
*/
|
|
70
|
+
verify(data, signature) {
|
|
71
|
+
//Every key is tried, to not reveal which one signed
|
|
72
|
+
let valid = false;
|
|
73
|
+
for (const key of this.keys)
|
|
74
|
+
valid = verifyHMAC(data, signature, key, this.algorithm) || valid;
|
|
75
|
+
return valid;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Sign a message and append the signature: `<message>.<signature>`. The message is not encrypted: anyone can read
|
|
79
|
+
* it, only a key holder can change it.
|
|
80
|
+
* @param message The message to sign; it may contain dots (the signature follows the last one).
|
|
81
|
+
* @returns The signed message, to read back with {@link Mac.unsignMessage}.
|
|
82
|
+
*/
|
|
83
|
+
signMessage(message) {
|
|
84
|
+
return `${message}.${this.sign(message)}`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Check a message signed by {@link Mac.signMessage} with any of the keys, and return it without its signature.
|
|
88
|
+
* @param signedMessage The signed message, `<message>.<signature>`.
|
|
89
|
+
* @returns The original message.
|
|
90
|
+
* @throws {InvalidSignatureException} When the signed message has no dot, or its signature is not valid (the message
|
|
91
|
+
* or the signature was changed, or none of the keys signed it).
|
|
92
|
+
*/
|
|
93
|
+
unsignMessage(signedMessage) {
|
|
94
|
+
const index = signedMessage.lastIndexOf('.');
|
|
95
|
+
if (index >= 0) {
|
|
96
|
+
const message = signedMessage.slice(0, index);
|
|
97
|
+
if (this.verify(message, signedMessage.slice(index + 1)))
|
|
98
|
+
return message;
|
|
99
|
+
}
|
|
100
|
+
throw new InvalidSignatureException('The signature of the message is not valid');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
__decorate([
|
|
104
|
+
Configurable(DTO.Array().items(DTO.String().min(16)).min(1).required()),
|
|
105
|
+
__metadata("design:type", Array)
|
|
106
|
+
], Mac.prototype, "keys", void 0);
|
|
107
|
+
__decorate([
|
|
108
|
+
Configurable(DTO.String().valid('sha256', 'sha384', 'sha512').optional().default('sha256')),
|
|
109
|
+
__metadata("design:type", String)
|
|
110
|
+
], Mac.prototype, "algorithm", void 0);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import * as bcrypt from '../lib/bcrypt/index.js';
|
|
11
|
+
import { DTO, Provider } from '@lakutata/core';
|
|
12
|
+
import { Configurable } from '@lakutata/core/decorator/di';
|
|
13
|
+
import { PasswordTooLongException } from '../exceptions/PasswordTooLongException.js';
|
|
14
|
+
/**
|
|
15
|
+
* The largest password bcrypt hashes entirely, in UTF-8 bytes (it ignores the following bytes)
|
|
16
|
+
*/
|
|
17
|
+
const MAX_PASSWORD_BYTES = 72;
|
|
18
|
+
/**
|
|
19
|
+
* A bcrypt hash: its variant, its cost (4 to 31), then its salt and its digest in bcrypt's base64 alphabet
|
|
20
|
+
*/
|
|
21
|
+
const BCRYPT_HASH = /^\$2[aby]\$(0[4-9]|[12]\d|3[01])\$[./A-Za-z0-9]{53}$/;
|
|
22
|
+
/**
|
|
23
|
+
* The password hashing provider: hashes passwords with bcrypt (a new random 16-byte salt per hash, the `$2b$` variant)
|
|
24
|
+
* and checks a password against a stored hash. Use it for user passwords: bcrypt is deliberately slow, so a stolen hash
|
|
25
|
+
* is costly to brute-force. Do not use it to hash tokens or keys (use {@link hmac}) or to encrypt (use {@link Crypt}).
|
|
26
|
+
*
|
|
27
|
+
* Declare it in the `providers` of the application (`{class: PasswordHash, saltRounds: 12}`) and get it with `@Inject`
|
|
28
|
+
* or `getObject()`; it is scoped, like every provider. A hash is a 60-character string (`$2b$10$` then the salt and the
|
|
29
|
+
* digest) holding its own salt and cost, so it is stored as is, in one column, and {@link PasswordHash.validate} needs
|
|
30
|
+
* nothing else; hashes of another cost (or the `$2a$`/`$2y$` variants of other bcrypt libraries) still validate.
|
|
31
|
+
*
|
|
32
|
+
* bcrypt only reads the first 72 bytes of a password (UTF-8): {@link PasswordHash.hash} rejects a longer password with
|
|
33
|
+
* {@link PasswordTooLongException} rather than hashing its prefix, and {@link PasswordHash.validate} gives `false` for
|
|
34
|
+
* it. bcrypt runs in JavaScript on the main thread, in
|
|
35
|
+
* slices of at most 100 ms between which the other requests proceed; a hash or a validation costs about 60 ms of CPU
|
|
36
|
+
* at cost 10 on a current CPU, doubling with each extra round.
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* import {Application} from 'lakutata'
|
|
40
|
+
* import {PasswordHash, PasswordTooLongException} from 'lakutata/provider/passwordHash'
|
|
41
|
+
*
|
|
42
|
+
* Application
|
|
43
|
+
* .run(() => ({
|
|
44
|
+
* id: 'auth.app',
|
|
45
|
+
* name: 'Auth',
|
|
46
|
+
* providers: {
|
|
47
|
+
* //Each extra round doubles the cost of a hash
|
|
48
|
+
* passwordHash: {class: PasswordHash, saltRounds: 12}
|
|
49
|
+
* }
|
|
50
|
+
* }))
|
|
51
|
+
* .onLaunched(async (app: Application): Promise<void> => {
|
|
52
|
+
* const passwordHash: PasswordHash = await app.getObject('passwordHash')
|
|
53
|
+
* try {
|
|
54
|
+
* //Stored as is: the hash holds its salt and its cost
|
|
55
|
+
* const hash: string = await passwordHash.hash('correct horse battery staple')
|
|
56
|
+
* console.log(await passwordHash.validate('correct horse battery staple', hash))
|
|
57
|
+
* console.log(await passwordHash.validate('wrong password', hash))
|
|
58
|
+
* } catch (error) {
|
|
59
|
+
* if (error instanceof PasswordTooLongException) console.log('Choose a password of 72 bytes at most')
|
|
60
|
+
* }
|
|
61
|
+
* })
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export class PasswordHash extends Provider {
|
|
65
|
+
/**
|
|
66
|
+
* A new random bcrypt salt with the configured cost (`$2b$10$` and 22 characters), different at each read.
|
|
67
|
+
* @deprecated Not needed: {@link PasswordHash.hash} generates a salt for each hash, and the hash holds it for
|
|
68
|
+
* {@link PasswordHash.validate}.
|
|
69
|
+
*/
|
|
70
|
+
get salt() {
|
|
71
|
+
return bcrypt.genSaltSync(this.saltRounds);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Hash a password with bcrypt, a new random salt and the configured {@link PasswordHash.saltRounds}. Two hashes of
|
|
75
|
+
* the same password differ: compare them with {@link PasswordHash.validate}, never with `===`.
|
|
76
|
+
* @param password The password, as typed by the user: at most 72 bytes once encoded in UTF-8 (72 ASCII
|
|
77
|
+
* characters, fewer with accented letters or emoji). An empty password is hashed too: reject it beforehand if needed.
|
|
78
|
+
* @returns The 60-character bcrypt hash (`$2b$<cost>$<salt><digest>`, bcrypt's own base64 alphabet), to store as is.
|
|
79
|
+
* @throws {PasswordTooLongException} When the password is longer than 72 bytes in UTF-8.
|
|
80
|
+
*/
|
|
81
|
+
async hash(password) {
|
|
82
|
+
//bcrypt ignores the bytes beyond the 72nd: a longer password would match any password sharing its 72 first bytes
|
|
83
|
+
if (bcrypt.truncates(password))
|
|
84
|
+
throw new PasswordTooLongException('The password is longer than {max} bytes', { max: MAX_PASSWORD_BYTES });
|
|
85
|
+
return await bcrypt.hash(password, this.saltRounds);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Check a password against a hash made by {@link PasswordHash.hash} (or by another bcrypt library: `$2a$`, `$2b$`,
|
|
89
|
+
* `$2y$`), with the salt and the cost the hash holds; the final comparison takes a constant time.
|
|
90
|
+
* @param password The password to check. A password longer than 72 bytes in UTF-8 gives `false`:
|
|
91
|
+
* {@link PasswordHash.hash} refuses it, so no hash can be its own.
|
|
92
|
+
* @param hash The stored bcrypt hash. A value which is not a bcrypt hash (not 60 characters long, a wrong prefix,
|
|
93
|
+
* cost or alphabet) gives `false`.
|
|
94
|
+
* @returns `true` when the password matches the hash.
|
|
95
|
+
*/
|
|
96
|
+
async validate(password, hash) {
|
|
97
|
+
//Never thrown on a malformed hash: a hash is data read from storage
|
|
98
|
+
if (typeof hash !== 'string' || !BCRYPT_HASH.test(hash))
|
|
99
|
+
return false;
|
|
100
|
+
//bcrypt would compare the first 72 bytes only: a longer password matches no hash made by hash()
|
|
101
|
+
if (bcrypt.truncates(password))
|
|
102
|
+
return false;
|
|
103
|
+
return await bcrypt.compare(password, hash);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
__decorate([
|
|
107
|
+
Configurable(DTO.Number().integer().min(4).max(31).optional().default(10)),
|
|
108
|
+
__metadata("design:type", Number)
|
|
109
|
+
], PasswordHash.prototype, "saltRounds", void 0);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Exception } from '@lakutata/core';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by {@link Crypt.decrypt}, {@link Crypt.decryptString}, {@link decryptByKey} and {@link decryptByPassword}
|
|
4
|
+
* when the data cannot be decrypted: a wrong key or password, data changed or truncated, or data not produced by the
|
|
5
|
+
* matching encryption function. The message does not tell these cases apart, on purpose. Its `errno` is
|
|
6
|
+
* `'E_DECRYPTION_FAILED'`; its HTTP status is 500 unless the application handles it.
|
|
7
|
+
*/
|
|
8
|
+
export declare class DecryptionFailedException extends Exception {
|
|
9
|
+
/**
|
|
10
|
+
* `'E_DECRYPTION_FAILED'`.
|
|
11
|
+
*/
|
|
12
|
+
errno: string | number;
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Exception } from '@lakutata/core';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by {@link Mac.unsignMessage} when the signed message has no signature or its signature is not valid (the
|
|
4
|
+
* message or the signature was changed, or none of the configured keys signed it); applications may throw it too
|
|
5
|
+
* after {@link verifyHMAC} fails. Its `errno` is `'E_INVALID_SIGNATURE'`; its HTTP status is 500 unless the application
|
|
6
|
+
* handles it.
|
|
7
|
+
*/
|
|
8
|
+
export declare class InvalidSignatureException extends Exception {
|
|
9
|
+
/**
|
|
10
|
+
* `'E_INVALID_SIGNATURE'`.
|
|
11
|
+
*/
|
|
12
|
+
errno: string | number;
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Exception } from '@lakutata/core';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by {@link PasswordHash.hash} when the password is longer than 72 bytes once encoded in UTF-8, the most bcrypt
|
|
4
|
+
* reads: hashing only its prefix would let any password sharing those 72 bytes match. Ask the user for a shorter
|
|
5
|
+
* password. Its `errno` is `'E_PASSWORD_TOO_LONG'`; its HTTP status is 500 unless the application handles it.
|
|
6
|
+
*/
|
|
7
|
+
export declare class PasswordTooLongException extends Exception {
|
|
8
|
+
/**
|
|
9
|
+
* `'E_PASSWORD_TOO_LONG'`.
|
|
10
|
+
*/
|
|
11
|
+
errno: string | number;
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Providers
|
|
3
|
+
*/
|
|
4
|
+
export * from '../providers/PasswordHash.js';
|
|
5
|
+
export * from '../providers/Mac.js';
|
|
6
|
+
export * from '../providers/Crypt.js';
|
|
7
|
+
/**
|
|
8
|
+
* Functions
|
|
9
|
+
*/
|
|
10
|
+
export * from '../functions/TimingSafeEqual.js';
|
|
11
|
+
export * from '../functions/GenerateSecret.js';
|
|
12
|
+
export * from '../functions/HMAC.js';
|
|
13
|
+
export * from '../functions/Encryption.js';
|
|
14
|
+
/**
|
|
15
|
+
* Exceptions
|
|
16
|
+
*/
|
|
17
|
+
export * from '../exceptions/PasswordTooLongException.js';
|
|
18
|
+
export * from '../exceptions/InvalidSignatureException.js';
|
|
19
|
+
export * from '../exceptions/DecryptionFailedException.js';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypt data with a secret key, using AES-256-GCM (authenticated encryption): the output is confidential and
|
|
3
|
+
* tamper-proof. Each call derives a fresh key from the key and a random 16-byte salt (HKDF-sha256) and uses a random
|
|
4
|
+
* 12-byte IV, so encrypting the same data twice gives different outputs. The {@link Crypt} provider does the same with
|
|
5
|
+
* configured keys and key rotation.
|
|
6
|
+
*
|
|
7
|
+
* The key must be random (see {@link generateSecret}): its derivation is fast, so a password used as a key is open to
|
|
8
|
+
* brute force; use {@link encryptByPassword} for passwords. The function refuses an empty key, but does not check
|
|
9
|
+
* the key's length or strength otherwise.
|
|
10
|
+
* @param data The data to encrypt: a string (encrypted as its UTF-8 bytes) or bytes.
|
|
11
|
+
* @param key The secret key: a string (used as its UTF-8 bytes, not decoded from base64) or bytes, not empty; 32
|
|
12
|
+
* random bytes recommended.
|
|
13
|
+
* @returns The encrypted data, base64url encoded without padding: 1 version byte, the salt, the IV, a 16-byte
|
|
14
|
+
* authentication tag and the ciphertext (45 bytes more than the data, before base64url).
|
|
15
|
+
* @throws {RangeError} When the key is empty.
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import {decryptByKey, encryptByKey, generateSecret} from 'lakutata/security'
|
|
19
|
+
*
|
|
20
|
+
* const key: string = process.env.CRYPT_KEY ?? generateSecret()
|
|
21
|
+
* const encrypted: string = encryptByKey('card ending 4242', key)
|
|
22
|
+
* console.log(decryptByKey(encrypted, key).toString('utf8'))
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function encryptByKey(data: string | Uint8Array, key: string | Uint8Array): string;
|
|
26
|
+
/**
|
|
27
|
+
* Decrypt data encrypted by {@link encryptByKey} (or {@link Crypt.encrypt}) with one of the keys, tried in order: list
|
|
28
|
+
* the current key and the previous ones during a key rotation. The authentication tag is checked before anything is
|
|
29
|
+
* returned, so changed data is never decrypted.
|
|
30
|
+
* @param encrypted The encrypted data, base64url as {@link encryptByKey} returns it.
|
|
31
|
+
* @param keys The key, or the keys to try: strings (used as their UTF-8 bytes) or bytes, none empty.
|
|
32
|
+
* @returns The decrypted bytes (call `.toString('utf8')` for a string).
|
|
33
|
+
* @throws {DecryptionFailedException} When no key decrypts the data, or it was changed, truncated or not produced by
|
|
34
|
+
* {@link encryptByKey} (data encrypted by {@link encryptByPassword} included).
|
|
35
|
+
* @throws {RangeError} When a key is empty.
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* import {decryptByKey, DecryptionFailedException} from 'lakutata/security'
|
|
39
|
+
*
|
|
40
|
+
* declare const encrypted: string
|
|
41
|
+
* declare const currentKey: string
|
|
42
|
+
* declare const previousKey: string
|
|
43
|
+
*
|
|
44
|
+
* try {
|
|
45
|
+
* //The new key first, the previous one while old data remains
|
|
46
|
+
* const secret: string = decryptByKey(encrypted, [currentKey, previousKey]).toString('utf8')
|
|
47
|
+
* console.log(secret)
|
|
48
|
+
* } catch (error) {
|
|
49
|
+
* if (error instanceof DecryptionFailedException) console.log('Changed, or encrypted with an unknown key')
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export declare function decryptByKey(encrypted: string, keys: string | Uint8Array | (string | Uint8Array)[]): Buffer;
|
|
54
|
+
/**
|
|
55
|
+
* Encrypt data with a password chosen by a person, using AES-256-GCM (authenticated encryption) with a key derived
|
|
56
|
+
* from the password and a random 16-byte salt by scrypt (N = 16384, r = 8, p = 1: about 16 MB of memory and a few
|
|
57
|
+
* tens of milliseconds per call, off the main thread). The slow derivation makes guessing the password costly; a
|
|
58
|
+
* strong password still matters. For a random key, {@link encryptByKey} is faster. To check a login password, use
|
|
59
|
+
* {@link PasswordHash} instead.
|
|
60
|
+
* @param data The data to encrypt: a string (encrypted as its UTF-8 bytes) or bytes.
|
|
61
|
+
* @param password The password: a string (used as its UTF-8 bytes) or bytes. Its length is not checked.
|
|
62
|
+
* @returns The encrypted data, base64url encoded without padding, in the format of {@link encryptByKey} with another
|
|
63
|
+
* version byte: the two are not interchangeable.
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* import {decryptByPassword, encryptByPassword} from 'lakutata/security'
|
|
67
|
+
*
|
|
68
|
+
* const encrypted: string = await encryptByPassword('backup contents', 'a long passphrase of the user')
|
|
69
|
+
* console.log((await decryptByPassword(encrypted, 'a long passphrase of the user')).toString('utf8'))
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export declare function encryptByPassword(data: string | Uint8Array, password: string | Uint8Array): Promise<string>;
|
|
73
|
+
/**
|
|
74
|
+
* Decrypt data encrypted by {@link encryptByPassword}, deriving the key from the password and the salt stored in the
|
|
75
|
+
* data (the same scrypt cost).
|
|
76
|
+
* @param encrypted The encrypted data, base64url as {@link encryptByPassword} returns it.
|
|
77
|
+
* @param password The password it was encrypted with: a string (used as its UTF-8 bytes) or bytes.
|
|
78
|
+
* @returns The decrypted bytes (call `.toString('utf8')` for a string).
|
|
79
|
+
* @throws {DecryptionFailedException} When the password is wrong, or the data was changed, truncated or not produced
|
|
80
|
+
* by {@link encryptByPassword} (data encrypted by {@link encryptByKey} included).
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* import {decryptByPassword, DecryptionFailedException} from 'lakutata/security'
|
|
84
|
+
*
|
|
85
|
+
* declare const encrypted: string
|
|
86
|
+
* declare const passphrase: string
|
|
87
|
+
*
|
|
88
|
+
* try {
|
|
89
|
+
* console.log((await decryptByPassword(encrypted, passphrase)).toString('utf8'))
|
|
90
|
+
* } catch (error) {
|
|
91
|
+
* if (error instanceof DecryptionFailedException) console.log('Wrong passphrase, or changed data')
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export declare function decryptByPassword(encrypted: string, password: string | Uint8Array): Promise<Buffer>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a random secret key from the operating system's cryptographically secure generator (`crypto.randomBytes`),
|
|
3
|
+
* suited to the keys of {@link Mac} and {@link Crypt}, {@link hmac} and {@link encryptByKey}, and to random tokens
|
|
4
|
+
* (session IDs, reset links). It is not a password and should be stored as a secret (an environment variable, a vault).
|
|
5
|
+
* @param bytes The number of random bytes; 32 (256 bits) by default, at least 16 for a secret (0 gives an empty
|
|
6
|
+
* string). The keys of {@link Mac} and {@link Crypt} need 12 bytes or more to reach their 16-character minimum.
|
|
7
|
+
* @returns The random bytes, base64url encoded without padding: 43 characters for 32 bytes, 22 for 16.
|
|
8
|
+
* @throws {RangeError} When `bytes` is negative or larger than 2^31 - 1.
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import {generateSecret} from 'lakutata/security'
|
|
12
|
+
*
|
|
13
|
+
* //A 256-bit key, for MAC_KEY or CRYPT_KEY
|
|
14
|
+
* console.log(generateSecret())
|
|
15
|
+
* //A 128-bit token
|
|
16
|
+
* const token: string = generateSecret(16)
|
|
17
|
+
* console.log(token)
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function generateSecret(bytes?: number): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hash functions of the HMAC signatures: `'sha256'` (43-character signatures), `'sha384'` (64) or `'sha512'` (86).
|
|
3
|
+
*/
|
|
4
|
+
export type HMACAlgorithm = 'sha256' | 'sha384' | 'sha512';
|
|
5
|
+
/**
|
|
6
|
+
* Sign data with a key: compute its hmac, a keyed hash that only a holder of the key can produce. Use it to detect
|
|
7
|
+
* tampering (webhooks, signed links); it does not hide the data, and is not a password hash. The {@link Mac} provider
|
|
8
|
+
* does the same with configured keys and key rotation.
|
|
9
|
+
* @param data The data to sign: a string (signed as its UTF-8 bytes) or bytes.
|
|
10
|
+
* @param key The secret key: a string (used as its UTF-8 bytes, not decoded) or bytes, not empty. Use a random key of
|
|
11
|
+
* 32 bytes, such as {@link generateSecret} returns.
|
|
12
|
+
* @param algorithm The hash function; `'sha256'` by default.
|
|
13
|
+
* @returns The hmac, base64url encoded without padding.
|
|
14
|
+
* @throws {RangeError} When the key is empty.
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* import {generateSecret, hmac, verifyHMAC} from 'lakutata/security'
|
|
18
|
+
*
|
|
19
|
+
* const key: string = process.env.WEBHOOK_KEY ?? generateSecret()
|
|
20
|
+
* const body: string = JSON.stringify({order: 42, status: 'paid'})
|
|
21
|
+
* //Sent in a header along with the body
|
|
22
|
+
* const signature: string = hmac(body, key, 'sha256')
|
|
23
|
+
* console.log(verifyHMAC(body, signature, key, 'sha256'))
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare function hmac(data: string | Uint8Array, key: string | Uint8Array, algorithm?: HMACAlgorithm): string;
|
|
27
|
+
/**
|
|
28
|
+
* Check that a signature is the HMAC of data with a key, in a time that does not depend on the signature (see
|
|
29
|
+
* {@link constantTimeEqual}).
|
|
30
|
+
* @param data The signed data, exactly as it was signed (the raw request body, not a re-serialized copy).
|
|
31
|
+
* @param signature The signature to check, base64url encoded without padding as {@link hmac} returns it; a hex or
|
|
32
|
+
* padded signature does not match.
|
|
33
|
+
* @param key The secret key the data was signed with: a string (used as its UTF-8 bytes) or bytes, not empty.
|
|
34
|
+
* @param algorithm The hash function the data was signed with; `'sha256'` by default.
|
|
35
|
+
* @returns `true` when the signature matches.
|
|
36
|
+
* @throws {RangeError} When the key is empty.
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* import {InvalidSignatureException, verifyHMAC} from 'lakutata/security'
|
|
40
|
+
*
|
|
41
|
+
* declare const rawBody: string
|
|
42
|
+
* declare const signatureHeader: string
|
|
43
|
+
* declare const webhookKey: string
|
|
44
|
+
*
|
|
45
|
+
* if (!verifyHMAC(rawBody, signatureHeader, webhookKey)) {
|
|
46
|
+
* throw new InvalidSignatureException('The signature of the webhook is not valid')
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export declare function verifyHMAC(data: string | Uint8Array, signature: string, key: string | Uint8Array, algorithm?: HMACAlgorithm): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare two secrets (signatures, tokens, API keys) in a time that does not depend on their content or their lengths,
|
|
3
|
+
* so an attacker cannot guess a secret by timing the comparisons. Values of different lengths are compared too, and
|
|
4
|
+
* are unequal. Use it instead of `===` whenever one side is a secret; to check a password, use
|
|
5
|
+
* {@link PasswordHash.validate} instead.
|
|
6
|
+
* @param a The first value: a string (compared as its UTF-8 bytes) or bytes.
|
|
7
|
+
* @param b The second value: a string (compared as its UTF-8 bytes) or bytes.
|
|
8
|
+
* @returns `true` when both values hold the same bytes.
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import {constantTimeEqual} from 'lakutata/security'
|
|
12
|
+
*
|
|
13
|
+
* declare const presentedApiKey: string
|
|
14
|
+
*
|
|
15
|
+
* if (!constantTimeEqual(presentedApiKey, process.env.API_KEY ?? '')) throw new Error('Unauthorized')
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function constantTimeEqual(a: string | Uint8Array, b: string | Uint8Array): boolean;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derived from the upstream index.js (see UPSTREAM.md; BSD-3-Clause, see THIRD_PARTY_NOTICES.md)
|
|
3
|
+
*/
|
|
4
|
+
declare const BASE64_CODE: readonly string[];
|
|
5
|
+
declare const BASE64_INDEX: readonly number[];
|
|
6
|
+
declare const BCRYPT_SALT_LEN: number;
|
|
7
|
+
declare const GENSALT_DEFAULT_LOG2_ROUNDS: number;
|
|
8
|
+
declare const BLOWFISH_NUM_ROUNDS: number;
|
|
9
|
+
declare const MAX_EXECUTION_TIME: number;
|
|
10
|
+
declare const P_ORIG: readonly number[];
|
|
11
|
+
declare const S_ORIG: readonly number[];
|
|
12
|
+
declare const C_ORIG: readonly number[];
|
|
13
|
+
export { BASE64_CODE, BASE64_INDEX, BCRYPT_SALT_LEN, GENSALT_DEFAULT_LOG2_ROUNDS, BLOWFISH_NUM_ROUNDS, MAX_EXECUTION_TIME, P_ORIG, S_ORIG, C_ORIG };
|