@lakutata/security 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +28 -22
  2. package/README.zh-CN.md +132 -0
  3. package/package.json +26 -3
package/README.md CHANGED
@@ -1,9 +1,12 @@
1
1
  # @lakutata/security
2
2
 
3
+ English | [中文](README.zh-CN.md)
4
+
3
5
  The security toolkit of the lakutata framework, built on `node:crypto`: password hashing with bcrypt (`PasswordHash`),
4
- message signatures with HMAC (`Mac`), authenticated encryption with AES-256-GCM (`Crypt`), with key rotation, and the
5
- functions under them (random keys, constant-time comparison, encryption with a key or a password). The umbrella package
6
- `lakutata` re-exports it as `lakutata/security`, and `PasswordHash` alone as `lakutata/provider/passwordHash`.
6
+ message signatures with HMAC (`Mac`) and authenticated encryption with AES-256-GCM (`Crypt`), both with key rotation,
7
+ plus lower-level functions (HMAC signatures, random keys, constant-time comparison, encryption with a key or a
8
+ password). The umbrella package `lakutata` re-exports it as `lakutata/security`, and `PasswordHash` alone as
9
+ `lakutata/provider/passwordHash`.
7
10
 
8
11
  ## Installation
9
12
 
@@ -17,8 +20,8 @@ It has no optional dependencies: bcrypt is implemented in JavaScript, the rest u
17
20
 
18
21
  ### The providers in an application
19
22
 
20
- Declare the providers in the `providers` of the application, with their keys read from the environment, then get them
21
- with `getObject()` or `@Inject`. They are scoped, like every provider.
23
+ Declare the providers in the application's `providers`, with their keys read from the environment, then get them with
24
+ `getObject()` or `@Inject`. They are scoped, the default lifetime of providers.
22
25
 
23
26
  ```typescript
24
27
  import {Application} from 'lakutata'
@@ -60,18 +63,19 @@ Application
60
63
  | Generate a key or a token | `generateSecret()` | — | base64url random bytes (32 by default) |
61
64
  | Compare a secret received with the expected one | `constantTimeEqual()` | no: use `PasswordHash.validate()` | boolean |
62
65
 
63
- - **Passwords**: `PasswordHash.hash()` rejects passwords longer than 72 bytes in UTF-8 (bcrypt ignores the following
64
- bytes) with `PasswordTooLongException`, and `validate()` gives `false` for them. Each hash has its own random salt and
65
- its cost (`saltRounds`, 4 to 31, 10 by default: each extra round doubles the time); store the hash as is and check it
66
- with `validate()`, never with `===`. `validate()` gives `false` for a value which is not a bcrypt hash.
67
- - **Keys**: the keys of `Mac` and `Crypt` are strings of at least 16 characters, used as their UTF-8 bytes. Generate
68
- them with `generateSecret()` (32 random bytes): the key derivations of `Mac`, `Crypt` and `encryptByKey()` are fast, so a
69
- password used as a key is open to brute force. The functions refuse an empty key with a `RangeError`, but do not
70
- check the key's length otherwise.
71
- - **Key rotation**: put the new key first and keep the previous ones while the data they signed or encrypted is read;
72
- the first key signs and encrypts, every key verifies and decrypts.
73
- - **Formats**: the signatures and the encrypted data are base64url strings without padding, safe in URLs and cookies.
74
- The encrypted data is authenticated: any change makes the decryption fail rather than return altered data.
66
+ - **Passwords**: `PasswordHash.hash()` rejects a password longer than 72 bytes in UTF-8 with
67
+ `PasswordTooLongException` (bcrypt would ignore the extra bytes), and `validate()` returns `false` for such
68
+ passwords. Each hash includes its own random salt and its cost (`saltRounds`, 4 to 31, 10 by default; each extra
69
+ round doubles the time). Store the hash as is and check it with `validate()`, never with `===`. `validate()` returns
70
+ `false` for a value that is not a bcrypt hash.
71
+ - **Keys**: the keys of `Mac` and `Crypt` are strings of at least 16 characters, used as UTF-8 bytes. Generate them
72
+ with `generateSecret()` (32 random bytes): `Mac`, `Crypt` and `encryptByKey()` process their keys quickly, without
73
+ slow key stretching, so a password used as a key is open to brute force. The functions reject an empty key with a
74
+ `RangeError` but do not otherwise check its length.
75
+ - **Key rotation**: put the new key first, and keep the previous ones as long as data they signed or encrypted is still
76
+ read. The first key signs and encrypts; every key verifies and decrypts.
77
+ - **Formats**: signatures and encrypted data are base64url strings without padding, safe in URLs and cookies.
78
+ Encrypted data is authenticated: any change makes decryption fail instead of returning altered data.
75
79
 
76
80
  ### The functions
77
81
 
@@ -121,7 +125,8 @@ console.log(constantTimeEqual('presented-token', process.env.API_TOKEN ?? ''))
121
125
  | `constantTimeEqual` | Compare two secrets in constant time, whatever their lengths |
122
126
  | `PasswordTooLongException`, `InvalidSignatureException`, `DecryptionFailedException` | The exceptions |
123
127
 
124
- All are exported by `lakutata/security` and `@lakutata/security`. The typings document each declaration with examples.
128
+ All of them are exported by `lakutata/security` and `@lakutata/security`. The typings document each declaration with
129
+ examples.
125
130
 
126
131
  ## Errors
127
132
 
@@ -130,14 +135,15 @@ All are exported by `lakutata/security` and `@lakutata/security`. The typings do
130
135
  | `PasswordTooLongException` (`E_PASSWORD_TOO_LONG`) | `PasswordHash.hash()` with a password longer than 72 bytes in UTF-8 |
131
136
  | `InvalidSignatureException` (`E_INVALID_SIGNATURE`) | `Mac.unsignMessage()` with a message changed, unsigned or signed by an unknown key |
132
137
  | `DecryptionFailedException` (`E_DECRYPTION_FAILED`) | `Crypt.decrypt()`, `decryptString()`, `decryptByKey()`, `decryptByPassword()`: a wrong key or password, changed or truncated data |
133
- | `InvalidObjectOptionsException` (from `lakutata`) | The application starts with invalid provider options: no key, a key shorter than 16 characters, an unknown algorithm, `saltRounds` outside 4 to 31 (`InvalidValueException` when the provider is created outside the checked options, as in a bare `Container`) |
138
+ | `InvalidObjectOptionsException` (from `lakutata`) | The application starts with invalid provider options: no key, a key shorter than 16 characters, an unknown algorithm, `saltRounds` outside 4 to 31 (a provider created without this check, by a bare `Container` for example, throws `InvalidValueException` instead) |
134
139
  | `RangeError` | `generateSecret()` with a negative or too large number of bytes; `hmac()`, `verifyHMAC()`, `encryptByKey()`, `decryptByKey()` with an empty key |
135
140
 
136
- The exceptions have the HTTP status 500 unless the application maps them; `verify()`, `verifyHMAC()` (given a key),
141
+ These exceptions have the HTTP status 500 unless the application maps them. `verify()`, `verifyHMAC()` (given a key),
137
142
  `validate()` and `constantTimeEqual()` return `false` for a wrong or malformed signature, hash or secret instead of
138
143
  throwing.
139
144
 
140
145
  ## See also
141
146
 
142
- - `doc/en/Security.md` of the package `lakutata` (Chinese in `doc/zh`): the guide of the security tools.
143
- - `@lakutata/core` (`lakutata`): the providers, `Application` and the dependency injection.
147
+ - The guide on the website: [Security](https://lakutata.com/guide/Security).
148
+ - `doc/en/Security.md` in the `lakutata` package (Chinese in `doc/zh`): the guide to the security tools.
149
+ - `@lakutata/core` (`lakutata`): providers, `Application` and dependency injection.
@@ -0,0 +1,132 @@
1
+ # @lakutata/security
2
+
3
+ [English](README.md) | 中文
4
+
5
+ lakutata 框架的安全工具包,构建在 `node:crypto` 之上:基于 bcrypt 的密码哈希(`PasswordHash`)、基于 HMAC 的消息签名(`Mac`)和基于 AES-256-GCM 的认证加密(`Crypt`),后两者都支持密钥轮换;另外还有更底层的函数(HMAC 签名、随机密钥、恒定时间比较、用密钥或口令加密)。`lakutata` 包通过 `lakutata/security` 重新导出它,并通过 `lakutata/provider/passwordHash` 单独导出 `PasswordHash`。
6
+
7
+ ## 安装
8
+
9
+ ```shell
10
+ npm install lakutata
11
+ ```
12
+
13
+ 它没有可选依赖:bcrypt 用 JavaScript 实现,其余部分使用 `node:crypto`。
14
+
15
+ ## 用法
16
+
17
+ ### 在应用中使用提供者
18
+
19
+ 在应用的 `providers` 中声明这些提供者,密钥从环境变量读取,然后通过 `getObject()` 或 `@Inject` 获取它们。它们的生命周期类型是作用域(scoped),即提供者的默认类型。
20
+
21
+ ```typescript
22
+ import {Application} from 'lakutata'
23
+ import {Crypt, generateSecret, Mac, PasswordHash} from 'lakutata/security'
24
+
25
+ Application
26
+ .run(() => ({
27
+ id: 'security.app',
28
+ name: 'Security',
29
+ providers: {
30
+ passwordHash: {class: PasswordHash, saltRounds: 12},
31
+ //第一个密钥用于签名和加密,所有密钥都可用于验证和解密
32
+ mac: {class: Mac, keys: [process.env.MAC_KEY ?? generateSecret()], algorithm: 'sha256'},
33
+ crypt: {class: Crypt, keys: [process.env.CRYPT_KEY ?? generateSecret(), process.env.CRYPT_PREVIOUS_KEY ?? generateSecret()]}
34
+ }
35
+ }))
36
+ .onLaunched(async (app: Application): Promise<void> => {
37
+ const passwordHash: PasswordHash = await app.getObject('passwordHash')
38
+ const hash: string = await passwordHash.hash('correct horse battery staple')
39
+ console.log(await passwordHash.validate('correct horse battery staple', hash))
40
+
41
+ const mac: Mac = await app.getObject('mac')
42
+ const token: string = mac.signMessage('user:42')
43
+ console.log(mac.unsignMessage(token))
44
+
45
+ const crypt: Crypt = await app.getObject('crypt')
46
+ console.log(crypt.decryptString(crypt.encrypt('api-token-123')))
47
+ })
48
+ ```
49
+
50
+ ### 按用途选择工具
51
+
52
+ | 需求 | 使用 | 适合密码吗 | 输出 |
53
+ |---|---|---|---|
54
+ | 保存用户密码,登录时校验 | `PasswordHash`(bcrypt) | 适合:慢且加盐 | 60 个字符的 bcrypt 哈希(`$2b$10$...`) |
55
+ | 检测篡改(Cookie、链接、webhook) | `Mac`、`hmac()` / `verifyHMAC()` | 不适合:速度快,需要随机密钥 | base64url 格式的 HMAC(使用 SHA-256 时为 43 个字符) |
56
+ | 隐藏和保护数据,之后还能读回 | `Crypt`、`encryptByKey()` / `decryptByKey()` | 不适合:需要随机密钥 | base64url:版本、盐、IV、标签、密文 |
57
+ | 根据用户输入的口令加密 | `encryptByPassword()` / `decryptByPassword()` | 适合,用于加密(scrypt) | 格式相同,版本不同 |
58
+ | 生成密钥或令牌 | `generateSecret()` | — | base64url 格式的随机字节(默认 32 个) |
59
+ | 比较收到的秘密与预期值 | `constantTimeEqual()` | 不适合:请用 `PasswordHash.validate()` | 布尔值 |
60
+
61
+ - **密码**:`PasswordHash.hash()` 拒绝 UTF-8 编码超过 72 字节的密码,抛出 `PasswordTooLongException`(bcrypt 会忽略超出的字节);对这类密码,`validate()` 返回 `false`。每个哈希都包含它自己的随机盐和计算成本(`saltRounds`,4 到 31,默认 10;每多一轮,耗时翻倍)。哈希值请原样保存,并用 `validate()` 校验,不要用 `===` 比较。值不是 bcrypt 哈希时,`validate()` 返回 `false`。
62
+ - **密钥**:`Mac` 和 `Crypt` 的密钥是至少 16 个字符的字符串,按 UTF-8 字节使用。请用 `generateSecret()` 生成(32 个随机字节):`Mac`、`Crypt` 和 `encryptByKey()` 处理密钥很快,不做慢速的密钥拉伸,所以把密码当作密钥使用容易被暴力破解。这些函数遇到空密钥会抛出 `RangeError`,除此之外不检查密钥的长度。
63
+ - **密钥轮换**:把新密钥放在最前面;只要还要读取用旧密钥签名或加密的数据,就保留旧密钥。第一个密钥用于签名和加密,所有密钥都可用于验证和解密。
64
+ - **格式**:签名和加密数据都是不带填充的 base64url 字符串,可以安全地用于 URL 和 Cookie。加密数据经过认证:数据有任何改动,解密都会失败,而不是返回被改过的数据。
65
+
66
+ ### 函数
67
+
68
+ ```typescript
69
+ import {
70
+ decryptByKey,
71
+ decryptByPassword,
72
+ encryptByKey,
73
+ encryptByPassword,
74
+ generateSecret,
75
+ hmac,
76
+ constantTimeEqual,
77
+ verifyHMAC
78
+ } from 'lakutata/security'
79
+
80
+ const key: string = generateSecret()
81
+
82
+ //签名:原始请求体,以及放在请求头中发送的签名
83
+ const body: string = JSON.stringify({order: 42})
84
+ const signature: string = hmac(body, key, 'sha512')
85
+ console.log(verifyHMAC(body, signature, key, 'sha512'))
86
+
87
+ //用随机密钥加密,用当前的或之前的密钥解密
88
+ const encrypted: string = encryptByKey('card ending 4242', key)
89
+ console.log(decryptByKey(encrypted, [key, generateSecret()]).toString('utf8'))
90
+
91
+ //用口令加密(scrypt,异步)
92
+ const backup: string = await encryptByPassword('backup contents', 'a long passphrase')
93
+ console.log((await decryptByPassword(backup, 'a long passphrase')).toString('utf8'))
94
+
95
+ //以恒定时间比较收到的令牌
96
+ console.log(constantTimeEqual('presented-token', process.env.API_TOKEN ?? ''))
97
+ ```
98
+
99
+ ## API
100
+
101
+ | 导出 | 说明 |
102
+ |---|---|
103
+ | `PasswordHash` | 用 bcrypt 哈希密码的提供者:`hash()`、`validate()`;选项 `saltRounds`(默认 10) |
104
+ | `Mac` | 用 HMAC 签名并支持密钥轮换的提供者:`sign()`、`verify()`、`signMessage()`、`unsignMessage()`;选项 `keys`、`algorithm`(默认 `'sha256'`) |
105
+ | `Crypt` | 用 AES-256-GCM 加密并支持密钥轮换的提供者:`encrypt()`、`decrypt()`、`decryptString()`;选项 `keys` |
106
+ | `hmac`、`verifyHMAC` | 用密钥签名数据;以恒定时间校验签名 |
107
+ | `HMACAlgorithm` | 签名使用的哈希函数:`'sha256'`、`'sha384'`、`'sha512'` |
108
+ | `encryptByKey`、`decryptByKey` | 用随机密钥做认证加密(每条消息使用 HKDF-SHA256) |
109
+ | `encryptByPassword`、`decryptByPassword` | 用口令做认证加密(scrypt) |
110
+ | `generateSecret` | 随机密钥或令牌,base64url 格式 |
111
+ | `constantTimeEqual` | 以恒定时间比较两个秘密,无论它们的长度是否相同 |
112
+ | `PasswordTooLongException`、`InvalidSignatureException`、`DecryptionFailedException` | 异常 |
113
+
114
+ 以上全部由 `lakutata/security` 和 `@lakutata/security` 导出。类型声明中的每一项都附有说明和示例。
115
+
116
+ ## 错误
117
+
118
+ | 异常 | 什么时候 |
119
+ |---|---|
120
+ | `PasswordTooLongException`(`E_PASSWORD_TOO_LONG`) | `PasswordHash.hash()` 的密码 UTF-8 编码超过 72 字节 |
121
+ | `InvalidSignatureException`(`E_INVALID_SIGNATURE`) | `Mac.unsignMessage()` 的消息被篡改过、没有签名,或者由未知的密钥签名 |
122
+ | `DecryptionFailedException`(`E_DECRYPTION_FAILED`) | `Crypt.decrypt()`、`decryptString()`、`decryptByKey()`、`decryptByPassword()`:密钥或口令错误,数据被篡改或截断 |
123
+ | `InvalidObjectOptionsException`(来自 `lakutata`) | 应用启动时提供者的选项不合法:没有密钥、密钥短于 16 个字符、未知算法、`saltRounds` 不在 4 到 31 之间(不经过这项检查创建的提供者,例如在单独的 `Container` 中创建时,抛出的是 `InvalidValueException`) |
124
+ | `RangeError` | `generateSecret()` 的字节数为负数或过大;`hmac()`、`verifyHMAC()`、`encryptByKey()`、`decryptByKey()` 的密钥为空 |
125
+
126
+ 除非应用另行映射,这些异常的 HTTP 状态码都是 500。`verify()`、`verifyHMAC()`(给出了密钥时)、`validate()` 和 `constantTimeEqual()` 遇到错误或格式不正确的签名、哈希或秘密时返回 `false`,而不是抛出异常。
127
+
128
+ ## 另见
129
+
130
+ - 网站上的指南:[安全](https://lakutata.com/zh/guide/Security)。
131
+ - `lakutata` 包中的 `doc/zh/Security.md`:安全工具指南。
132
+ - `@lakutata/core`(`lakutata`):提供者、`Application` 和依赖注入。
package/package.json CHANGED
@@ -1,7 +1,30 @@
1
1
  {
2
2
  "name": "@lakutata/security",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Security providers and functions of the lakutata framework (password hashing, signatures, encryption).",
5
+ "keywords": [
6
+ "lakutata",
7
+ "security",
8
+ "password-hashing",
9
+ "bcrypt",
10
+ "hmac",
11
+ "aes-gcm",
12
+ "encryption",
13
+ "crypto",
14
+ "typescript"
15
+ ],
16
+ "homepage": "https://lakutata.com/packages/security",
17
+ "bugs": {
18
+ "url": "https://github.com/lakutata/lakutata/issues",
19
+ "email": "admin@lakutata.com"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/lakutata/lakutata.git",
24
+ "directory": "packages/security"
25
+ },
26
+ "author": "alex.ma",
27
+ "license": "MIT",
5
28
  "type": "module",
6
29
  "main": "./dist/cjs/exports/Security.js",
7
30
  "types": "./dist/cjs/exports/Security.d.ts",
@@ -29,10 +52,10 @@
29
52
  "test:unit": "npm run compile && node --test \"dist/esm/tests/*.spec.js\""
30
53
  },
31
54
  "peerDependencies": {
32
- "@lakutata/core": "3.0.0"
55
+ "@lakutata/core": "3.1.0"
33
56
  },
34
57
  "engines": {
35
- "node": ">=22"
58
+ "node": "^22.13.0 || >=24.11.0"
36
59
  },
37
60
  "files": [
38
61
  "dist",