@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.
- package/README.md +28 -22
- package/README.zh-CN.md +132 -0
- 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`)
|
|
5
|
-
functions
|
|
6
|
-
`lakutata` re-exports it as `lakutata/security`, and `PasswordHash` alone as
|
|
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
|
|
21
|
-
|
|
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
|
|
64
|
-
bytes)
|
|
65
|
-
its cost (`saltRounds`, 4 to 31, 10 by default
|
|
66
|
-
with `validate()`, never with `===`. `validate()`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
-
|
|
143
|
-
-
|
|
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.
|
package/README.zh-CN.md
ADDED
|
@@ -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.
|
|
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.
|
|
55
|
+
"@lakutata/core": "3.1.0"
|
|
33
56
|
},
|
|
34
57
|
"engines": {
|
|
35
|
-
"node": ">=
|
|
58
|
+
"node": "^22.13.0 || >=24.11.0"
|
|
36
59
|
},
|
|
37
60
|
"files": [
|
|
38
61
|
"dist",
|