@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.
Files changed (61) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +143 -0
  3. package/THIRD_PARTY_NOTICES.md +36 -0
  4. package/dist/cjs/exceptions/DecryptionFailedException.d.ts +13 -0
  5. package/dist/cjs/exceptions/DecryptionFailedException.js +20 -0
  6. package/dist/cjs/exceptions/InvalidSignatureException.d.ts +13 -0
  7. package/dist/cjs/exceptions/InvalidSignatureException.js +20 -0
  8. package/dist/cjs/exceptions/PasswordTooLongException.d.ts +12 -0
  9. package/dist/cjs/exceptions/PasswordTooLongException.js +19 -0
  10. package/dist/cjs/exports/Security.d.ts +19 -0
  11. package/dist/cjs/exports/Security.js +38 -0
  12. package/dist/cjs/functions/Encryption.d.ts +95 -0
  13. package/dist/cjs/functions/Encryption.js +208 -0
  14. package/dist/cjs/functions/GenerateSecret.d.ts +20 -0
  15. package/dist/cjs/functions/GenerateSecret.js +26 -0
  16. package/dist/cjs/functions/HMAC.d.ts +50 -0
  17. package/dist/cjs/functions/HMAC.js +58 -0
  18. package/dist/cjs/functions/TimingSafeEqual.d.ts +18 -0
  19. package/dist/cjs/functions/TimingSafeEqual.js +30 -0
  20. package/dist/cjs/lib/AssertKey.d.ts +5 -0
  21. package/dist/cjs/lib/AssertKey.js +11 -0
  22. package/dist/cjs/lib/bcrypt/constants.d.ts +13 -0
  23. package/dist/cjs/lib/bcrypt/constants.js +213 -0
  24. package/dist/cjs/lib/bcrypt/index.d.ts +84 -0
  25. package/dist/cjs/lib/bcrypt/index.js +658 -0
  26. package/dist/cjs/package.json +1 -0
  27. package/dist/cjs/providers/Crypt.d.ts +71 -0
  28. package/dist/cjs/providers/Crypt.js +88 -0
  29. package/dist/cjs/providers/Mac.d.ts +86 -0
  30. package/dist/cjs/providers/Mac.js +114 -0
  31. package/dist/cjs/providers/PasswordHash.d.ts +78 -0
  32. package/dist/cjs/providers/PasswordHash.js +146 -0
  33. package/dist/esm/exceptions/DecryptionFailedException.js +16 -0
  34. package/dist/esm/exceptions/InvalidSignatureException.js +16 -0
  35. package/dist/esm/exceptions/PasswordTooLongException.js +15 -0
  36. package/dist/esm/exports/Security.js +19 -0
  37. package/dist/esm/functions/Encryption.js +202 -0
  38. package/dist/esm/functions/GenerateSecret.js +23 -0
  39. package/dist/esm/functions/HMAC.js +54 -0
  40. package/dist/esm/functions/TimingSafeEqual.js +27 -0
  41. package/dist/esm/lib/AssertKey.js +8 -0
  42. package/dist/esm/lib/bcrypt/constants.js +202 -0
  43. package/dist/esm/lib/bcrypt/index.js +645 -0
  44. package/dist/esm/providers/Crypt.js +84 -0
  45. package/dist/esm/providers/Mac.js +110 -0
  46. package/dist/esm/providers/PasswordHash.js +109 -0
  47. package/dist/types/exceptions/DecryptionFailedException.d.ts +13 -0
  48. package/dist/types/exceptions/InvalidSignatureException.d.ts +13 -0
  49. package/dist/types/exceptions/PasswordTooLongException.d.ts +12 -0
  50. package/dist/types/exports/Security.d.ts +19 -0
  51. package/dist/types/functions/Encryption.d.ts +95 -0
  52. package/dist/types/functions/GenerateSecret.d.ts +20 -0
  53. package/dist/types/functions/HMAC.d.ts +50 -0
  54. package/dist/types/functions/TimingSafeEqual.d.ts +18 -0
  55. package/dist/types/lib/AssertKey.d.ts +5 -0
  56. package/dist/types/lib/bcrypt/constants.d.ts +13 -0
  57. package/dist/types/lib/bcrypt/index.d.ts +84 -0
  58. package/dist/types/providers/Crypt.d.ts +71 -0
  59. package/dist/types/providers/Mac.d.ts +86 -0
  60. package/dist/types/providers/PasswordHash.d.ts +78 -0
  61. package/package.json +45 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A progress callback: the ratio of the rounds done (0 to 1), at most once per 100 ms
3
+ */
4
+ export type ProgressCallback = (progress: number) => void;
5
+ /**
6
+ * Bytes: an array of byte values, or a typed array
7
+ */
8
+ type Bytes = ArrayLike<number>;
9
+ /**
10
+ * Synchronously generates a salt.
11
+ * @param rounds Number of rounds to use, defaults to 10 if omitted (from 4 to 31)
12
+ * @returns Resulting salt
13
+ */
14
+ export declare function genSaltSync(rounds?: number): string;
15
+ /**
16
+ * Asynchronously generates a salt.
17
+ * @param rounds Number of rounds to use, defaults to 10 if omitted
18
+ * @returns Resulting salt
19
+ */
20
+ export declare function genSalt(rounds?: number): Promise<string>;
21
+ /**
22
+ * Synchronously generates a hash for the given password.
23
+ * @param password Password to hash
24
+ * @param salt Salt length to generate or salt to use, default to 10
25
+ * @returns Resulting hash
26
+ */
27
+ export declare function hashSync(password: string, salt?: number | string): string;
28
+ /**
29
+ * Asynchronously generates a hash for the given password.
30
+ * @param password Password to hash
31
+ * @param salt Salt length to generate or salt to use
32
+ * @param progressCallback Callback successively called with the percentage of rounds completed (0.0 - 1.0), maximally
33
+ * once per `MAX_EXECUTION_TIME = 100` ms.
34
+ * @returns Resulting hash
35
+ */
36
+ export declare function hash(password: string, salt: number | string, progressCallback?: ProgressCallback): Promise<string>;
37
+ /**
38
+ * Synchronously tests a password against a hash.
39
+ * @param password Password to compare
40
+ * @param hash Hash to test against
41
+ * @returns true if matching, otherwise false
42
+ */
43
+ export declare function compareSync(password: string, hash: string): boolean;
44
+ /**
45
+ * Asynchronously tests a password against a hash.
46
+ * @param password Password to compare
47
+ * @param hashValue Hash to test against
48
+ * @param progressCallback Callback successively called with the percentage of rounds completed (0.0 - 1.0), maximally
49
+ * once per `MAX_EXECUTION_TIME = 100` ms.
50
+ * @returns true if matching, otherwise false
51
+ */
52
+ export declare function compare(password: string, hashValue: string, progressCallback?: ProgressCallback): Promise<boolean>;
53
+ /**
54
+ * Gets the number of rounds used to encrypt the specified hash.
55
+ * @param hash Hash to extract the used number of rounds from
56
+ * @returns Number of rounds used
57
+ */
58
+ export declare function getRounds(hash: string): number;
59
+ /**
60
+ * Gets the salt portion from a hash. Does not validate the hash.
61
+ * @param hash Hash to extract the salt from
62
+ * @returns Extracted salt part
63
+ */
64
+ export declare function getSalt(hash: string): string;
65
+ /**
66
+ * Tests if a password will be truncated when hashed, that is its length is greater than 72 bytes when converted to
67
+ * UTF-8.
68
+ * @param password The password to test
69
+ * @returns `true` if truncated, otherwise `false`
70
+ */
71
+ export declare function truncates(password: string): boolean;
72
+ /**
73
+ * Encodes a byte array to base64 with up to len bytes of input, using the custom bcrypt alphabet.
74
+ * @param bytes Byte array
75
+ * @param length Maximum input length
76
+ */
77
+ export declare function encodeBase64(bytes: Bytes, length: number): string;
78
+ /**
79
+ * Decodes a base64 encoded string to up to len bytes of output, using the custom bcrypt alphabet.
80
+ * @param string String to decode
81
+ * @param length Maximum output length
82
+ */
83
+ export declare function decodeBase64(string: string, length: number): number[];
84
+ export {};
@@ -0,0 +1,71 @@
1
+ import { Provider } from '@lakutata/core';
2
+ /**
3
+ * The encryption provider: encrypts data with AES-256-GCM (authenticated encryption), with key rotation (the first key
4
+ * encrypts, every key decrypts). Use it to keep secrets confidential at rest or through a client: tokens stored in a
5
+ * database, data put in a cookie. The encrypted data is also tamper-proof: changing a byte makes it fail to decrypt.
6
+ * It is not a password hash: a value that only needs to be checked, never read back, belongs to
7
+ * {@link PasswordHash}.
8
+ *
9
+ * Declare it in the `providers` of the application (`{class: Crypt, keys: [...]}`) and get it with `@Inject` or
10
+ * `getObject()`; it is scoped, like every provider. Each encryption derives a fresh key from the configured key and a
11
+ * random salt (HKDF-sha256) and uses a random IV, so encrypting the same data twice gives different outputs. The output
12
+ * is a base64url string (no padding) of 1 version byte, a 16-byte salt, a 12-byte IV, a 16-byte authentication tag and
13
+ * the ciphertext (45 bytes more than the data, before base64url), the same format as {@link encryptByKey}.
14
+ * @example
15
+ * ```typescript
16
+ * import {Application} from 'lakutata'
17
+ * import {Crypt, DecryptionFailedException, generateSecret} from 'lakutata/security'
18
+ *
19
+ * Application
20
+ * .run(() => ({
21
+ * id: 'vault.app',
22
+ * name: 'Vault',
23
+ * providers: {
24
+ * //The keys come from the environment: the new key first, the previous one kept to decrypt
25
+ * crypt: {class: Crypt, keys: [process.env.CRYPT_KEY ?? generateSecret(), process.env.CRYPT_PREVIOUS_KEY ?? generateSecret()]}
26
+ * }
27
+ * }))
28
+ * .onLaunched(async (app: Application): Promise<void> => {
29
+ * const crypt: Crypt = await app.getObject('crypt')
30
+ * const encrypted: string = crypt.encrypt('api-token-123')
31
+ * try {
32
+ * console.log(crypt.decryptString(encrypted))
33
+ * console.log(crypt.decrypt(encrypted).length)
34
+ * } catch (error) {
35
+ * if (error instanceof DecryptionFailedException) console.log('Changed, or encrypted with an unknown key')
36
+ * }
37
+ * })
38
+ * ```
39
+ */
40
+ export declare class Crypt extends Provider {
41
+ /**
42
+ * The secret keys, at least one, each a string of at least 16 characters used as its UTF-8 bytes (it is not decoded
43
+ * from base64). The first key encrypts, every key decrypts: to rotate, put the new key first and keep the previous
44
+ * ones while data encrypted with them is still read. Use random keys, such as the output of {@link generateSecret}: the
45
+ * key derivation is fast, so a low-entropy key (a password) is open to brute force; to encrypt with a password, use
46
+ * {@link encryptByPassword}. Required.
47
+ */
48
+ readonly keys: string[];
49
+ /**
50
+ * Encrypt data with the first key.
51
+ * @param data The data to encrypt: a string (encrypted as its UTF-8 bytes) or bytes.
52
+ * @returns The encrypted data, base64url encoded without padding.
53
+ */
54
+ encrypt(data: string | Uint8Array): string;
55
+ /**
56
+ * Decrypt data encrypted by {@link Crypt.encrypt} (or {@link encryptByKey}) with any of the keys, tried in order.
57
+ * @param encrypted The encrypted data, base64url as {@link Crypt.encrypt} returns it.
58
+ * @returns The decrypted bytes.
59
+ * @throws {DecryptionFailedException} When no key decrypts the data, or it was changed, truncated or not produced by
60
+ * {@link Crypt.encrypt}.
61
+ */
62
+ decrypt(encrypted: string): Buffer;
63
+ /**
64
+ * Decrypt data encrypted by {@link Crypt.encrypt} with any of the keys, and decode it as a UTF-8 string.
65
+ * @param encrypted The encrypted data, base64url as {@link Crypt.encrypt} returns it.
66
+ * @returns The decrypted string.
67
+ * @throws {DecryptionFailedException} When no key decrypts the data, or it was changed, truncated or not produced by
68
+ * {@link Crypt.encrypt}.
69
+ */
70
+ decryptString(encrypted: string): string;
71
+ }
@@ -0,0 +1,86 @@
1
+ import { Provider } from '@lakutata/core';
2
+ import { type HMACAlgorithm } from '../functions/HMAC.js';
3
+ /**
4
+ * The message signing provider: signs data with an HMAC (SHA-256 by default) and verifies the signatures, with key
5
+ * rotation (the first key signs, every key verifies). Use it to detect tampering with the data that goes through a
6
+ * client: signed cookies, links, webhook payloads, tokens. A signature proves the data was signed by a holder of a key;
7
+ * it does not hide the data (use {@link Crypt}) and is not a password hash (use {@link PasswordHash}).
8
+ *
9
+ * Declare it in the `providers` of the application (`{class: Mac, keys: [...]}`) and get it with `@Inject` or
10
+ * `getObject()`; it is scoped, like every provider. The signatures are base64url strings without padding (43
11
+ * characters with SHA-256, 64 with SHA-384, 86 with SHA-512) and never expire by themselves: put a time in the signed
12
+ * data to limit their validity. Verification takes a constant time and tries every key, so it does not reveal which
13
+ * key failed.
14
+ * @example
15
+ * ```typescript
16
+ * import {Application} from 'lakutata'
17
+ * import {generateSecret, InvalidSignatureException, Mac} from 'lakutata/security'
18
+ *
19
+ * Application
20
+ * .run(() => ({
21
+ * id: 'links.app',
22
+ * name: 'Links',
23
+ * providers: {
24
+ * //The keys come from the environment: the new key first, the previous one kept to verify
25
+ * mac: {class: Mac, keys: [process.env.MAC_KEY ?? generateSecret(), process.env.MAC_PREVIOUS_KEY ?? generateSecret()]}
26
+ * }
27
+ * }))
28
+ * .onLaunched(async (app: Application): Promise<void> => {
29
+ * const mac: Mac = await app.getObject('mac')
30
+ * //'user:42.<signature>'
31
+ * const token: string = mac.signMessage('user:42')
32
+ * try {
33
+ * console.log(mac.unsignMessage(token))
34
+ * } catch (error) {
35
+ * if (error instanceof InvalidSignatureException) console.log('Tampered token')
36
+ * }
37
+ * //A detached signature, sent alongside the body
38
+ * const signature: string = mac.sign('{"order":42}')
39
+ * console.log(mac.verify('{"order":42}', signature))
40
+ * })
41
+ * ```
42
+ */
43
+ export declare class Mac extends Provider {
44
+ /**
45
+ * The secret keys, at least one, each a string of at least 16 characters used as its UTF-8 bytes (it is not decoded
46
+ * from base64). The first key signs, every key verifies: to rotate, put the new key first and keep the previous ones
47
+ * while their signatures are still presented. Use random keys, such as the output of {@link generateSecret}, not
48
+ * passwords. Required.
49
+ */
50
+ readonly keys: string[];
51
+ /**
52
+ * The hash function of the HMAC: `'sha256'`, `'sha384'` or `'sha512'`. Changing it invalidates every signature made
53
+ * before.
54
+ * @default 'sha256'
55
+ */
56
+ readonly algorithm: HMACAlgorithm;
57
+ /**
58
+ * Sign data with the first key.
59
+ * @param data The data to sign: a string (signed as its UTF-8 bytes) or bytes.
60
+ * @returns The HMAC of the data, base64url encoded without padding.
61
+ */
62
+ sign(data: string | Uint8Array): string;
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: string | Uint8Array, signature: string): boolean;
71
+ /**
72
+ * Sign a message and append the signature: `<message>.<signature>`. The message is not encrypted: anyone can read
73
+ * it, only a key holder can change it.
74
+ * @param message The message to sign; it may contain dots (the signature follows the last one).
75
+ * @returns The signed message, to read back with {@link Mac.unsignMessage}.
76
+ */
77
+ signMessage(message: string): string;
78
+ /**
79
+ * Check a message signed by {@link Mac.signMessage} with any of the keys, and return it without its signature.
80
+ * @param signedMessage The signed message, `<message>.<signature>`.
81
+ * @returns The original message.
82
+ * @throws {InvalidSignatureException} When the signed message has no dot, or its signature is not valid (the message
83
+ * or the signature was changed, or none of the keys signed it).
84
+ */
85
+ unsignMessage(signedMessage: string): string;
86
+ }
@@ -0,0 +1,78 @@
1
+ import { Provider } from '@lakutata/core';
2
+ /**
3
+ * The password hashing provider: hashes passwords with bcrypt (a new random 16-byte salt per hash, the `$2b$` variant)
4
+ * and checks a password against a stored hash. Use it for user passwords: bcrypt is deliberately slow, so a stolen hash
5
+ * is costly to brute-force. Do not use it to hash tokens or keys (use {@link hmac}) or to encrypt (use {@link Crypt}).
6
+ *
7
+ * Declare it in the `providers` of the application (`{class: PasswordHash, saltRounds: 12}`) and get it with `@Inject`
8
+ * or `getObject()`; it is scoped, like every provider. A hash is a 60-character string (`$2b$10$` then the salt and the
9
+ * digest) holding its own salt and cost, so it is stored as is, in one column, and {@link PasswordHash.validate} needs
10
+ * nothing else; hashes of another cost (or the `$2a$`/`$2y$` variants of other bcrypt libraries) still validate.
11
+ *
12
+ * bcrypt only reads the first 72 bytes of a password (UTF-8): {@link PasswordHash.hash} rejects a longer password with
13
+ * {@link PasswordTooLongException} rather than hashing its prefix, and {@link PasswordHash.validate} gives `false` for
14
+ * it. bcrypt runs in JavaScript on the main thread, in
15
+ * slices of at most 100 ms between which the other requests proceed; a hash or a validation costs about 60 ms of CPU
16
+ * at cost 10 on a current CPU, doubling with each extra round.
17
+ * @example
18
+ * ```typescript
19
+ * import {Application} from 'lakutata'
20
+ * import {PasswordHash, PasswordTooLongException} from 'lakutata/provider/passwordHash'
21
+ *
22
+ * Application
23
+ * .run(() => ({
24
+ * id: 'auth.app',
25
+ * name: 'Auth',
26
+ * providers: {
27
+ * //Each extra round doubles the cost of a hash
28
+ * passwordHash: {class: PasswordHash, saltRounds: 12}
29
+ * }
30
+ * }))
31
+ * .onLaunched(async (app: Application): Promise<void> => {
32
+ * const passwordHash: PasswordHash = await app.getObject('passwordHash')
33
+ * try {
34
+ * //Stored as is: the hash holds its salt and its cost
35
+ * const hash: string = await passwordHash.hash('correct horse battery staple')
36
+ * console.log(await passwordHash.validate('correct horse battery staple', hash))
37
+ * console.log(await passwordHash.validate('wrong password', hash))
38
+ * } catch (error) {
39
+ * if (error instanceof PasswordTooLongException) console.log('Choose a password of 72 bytes at most')
40
+ * }
41
+ * })
42
+ * ```
43
+ */
44
+ export declare class PasswordHash extends Provider {
45
+ /**
46
+ * The bcrypt cost of the new hashes, as a base-2 logarithm: a hash runs 2^saltRounds iterations, so each extra round
47
+ * doubles the time of {@link PasswordHash.hash} (and of {@link PasswordHash.validate} on the hashes it makes). An
48
+ * integer from 4 (bcrypt's minimum) to 31. Raise it as far as the login latency allows (12 is a common choice); the
49
+ * existing hashes keep the cost they were made with.
50
+ * @default 10
51
+ */
52
+ readonly saltRounds: number;
53
+ /**
54
+ * A new random bcrypt salt with the configured cost (`$2b$10$` and 22 characters), different at each read.
55
+ * @deprecated Not needed: {@link PasswordHash.hash} generates a salt for each hash, and the hash holds it for
56
+ * {@link PasswordHash.validate}.
57
+ */
58
+ get salt(): string;
59
+ /**
60
+ * Hash a password with bcrypt, a new random salt and the configured {@link PasswordHash.saltRounds}. Two hashes of
61
+ * the same password differ: compare them with {@link PasswordHash.validate}, never with `===`.
62
+ * @param password The password, as typed by the user: at most 72 bytes once encoded in UTF-8 (72 ASCII
63
+ * characters, fewer with accented letters or emoji). An empty password is hashed too: reject it beforehand if needed.
64
+ * @returns The 60-character bcrypt hash (`$2b$<cost>$<salt><digest>`, bcrypt's own base64 alphabet), to store as is.
65
+ * @throws {PasswordTooLongException} When the password is longer than 72 bytes in UTF-8.
66
+ */
67
+ hash(password: string): Promise<string>;
68
+ /**
69
+ * Check a password against a hash made by {@link PasswordHash.hash} (or by another bcrypt library: `$2a$`, `$2b$`,
70
+ * `$2y$`), with the salt and the cost the hash holds; the final comparison takes a constant time.
71
+ * @param password The password to check. A password longer than 72 bytes in UTF-8 gives `false`:
72
+ * {@link PasswordHash.hash} refuses it, so no hash can be its own.
73
+ * @param hash The stored bcrypt hash. A value which is not a bcrypt hash (not 60 characters long, a wrong prefix,
74
+ * cost or alphabet) gives `false`.
75
+ * @returns `true` when the password matches the hash.
76
+ */
77
+ validate(password: string, hash: string): Promise<boolean>;
78
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@lakutata/security",
3
+ "version": "3.0.0-beta.0",
4
+ "description": "Security providers and functions of the lakutata framework (password hashing, signatures, encryption).",
5
+ "type": "module",
6
+ "main": "./dist/cjs/exports/Security.js",
7
+ "types": "./dist/cjs/exports/Security.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./dist/types/exports/Security.d.ts",
12
+ "default": "./dist/esm/exports/Security.js"
13
+ },
14
+ "require": {
15
+ "types": "./dist/cjs/exports/Security.d.ts",
16
+ "default": "./dist/cjs/exports/Security.js"
17
+ }
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "scripts": {
22
+ "clean": "node -e \"require('node:fs').rmSync('dist', {recursive: true, force: true})\"",
23
+ "build:cjs": "tsc -p tsconfig.cjs.json",
24
+ "build:esm": "tsc -p tsconfig.esm.json",
25
+ "build:cjs-pkg": "node ../../scripts/package-build/cjs-package.mjs",
26
+ "build:js": "npm run build:cjs && npm run build:esm && npm run build:cjs-pkg",
27
+ "rebuild": "npm run clean && npm run build:js",
28
+ "compile": "npm run rebuild",
29
+ "test:unit": "npm run compile && node --test \"dist/esm/tests/*.spec.js\""
30
+ },
31
+ "peerDependencies": {
32
+ "@lakutata/core": "3.0.0-beta.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "!dist/*/tests",
40
+ "*.md",
41
+ "LICENSE",
42
+ "package.json",
43
+ "THIRD_PARTY_NOTICES.md"
44
+ ]
45
+ }