@travelclw/proof-protocol 0.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/LICENSE +21 -0
- package/README.md +66 -0
- package/browser.d.ts +40 -0
- package/browser.js +255 -0
- package/core.d.ts +29 -0
- package/core.js +219 -0
- package/node.d.ts +78 -0
- package/package.json +69 -0
- package/server.js +308 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 travelclw
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @travelclw/proof-protocol
|
|
2
|
+
|
|
3
|
+
无框架依赖的请求 Proof 协议实现。公共包只负责协议规范化、浏览器密钥存储与签名、Node.js 服务端验证;Axios、Alova、Nest Guard、业务路由、登录页面和 UI 由使用方实现。
|
|
4
|
+
|
|
5
|
+
公共包不包含任何项目业务路径,不判断某个业务接口是否需要 Proof,也不包含或生成服务端密钥。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @travelclw/proof-protocol
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node.js 服务端验证还需要安装兼容的 `jose`:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add jose@^5
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 入口
|
|
20
|
+
|
|
21
|
+
- `@travelclw/proof-protocol`:协议常量、规范化、错误码和模式解析。
|
|
22
|
+
- `@travelclw/proof-protocol/browser`:IndexedDB P-256 Key Store 和 Proof 签名。
|
|
23
|
+
- `@travelclw/proof-protocol/node`:JWK 校验、Proof 验证和服务端配置解析。
|
|
24
|
+
|
|
25
|
+
## 浏览器配置
|
|
26
|
+
|
|
27
|
+
浏览器构建只读取:
|
|
28
|
+
|
|
29
|
+
- `PROOF_REQUIRED=0|1`
|
|
30
|
+
|
|
31
|
+
浏览器端不读取也不应获得任何服务端密钥。使用方需要为每个浏览器应用提供独立的 IndexedDB 名称。
|
|
32
|
+
|
|
33
|
+
## Node.js 环境变量
|
|
34
|
+
|
|
35
|
+
- `AUTH_PROOF_MODE=0|1|2` 或 `off|shadow|enforce`
|
|
36
|
+
- `AUTH_PROOF_DEVICE_SESSION_SECRET`
|
|
37
|
+
- `AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS`
|
|
38
|
+
- `AUTH_PROOF_TIME_TOLERANCE_SECONDS`
|
|
39
|
+
- `AUTH_PROOF_SHADOW_BLOCK_MISSING_PROOF=0|1`
|
|
40
|
+
|
|
41
|
+
服务端应在启动阶段调用 `resolveProofServerConfig()`:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const { resolveProofServerConfig } = require('@travelclw/proof-protocol/node')
|
|
45
|
+
|
|
46
|
+
const proofConfig = resolveProofServerConfig()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`AUTH_PROOF_MODE` 缺失时按 `enforce` 处理;只要模式不是显式 `off`,就必须由部署环境注入至少 32 个字符的独立 `AUTH_PROOF_DEVICE_SESSION_SECRET`,否则 `resolveProofServerConfig()` 立即抛错。它不读取通用 `SECRET`,也没有内置、示例或回退密钥。
|
|
50
|
+
|
|
51
|
+
环境变量中的非空值优先于调用方代码配置。缺少 TTL、时间窗口或 shadow 开关时使用协议默认值;显式配置非法值会直接报错,不会静默回退。
|
|
52
|
+
|
|
53
|
+
## 安全边界
|
|
54
|
+
|
|
55
|
+
- 不要把 `.env`、真实密钥、Token、Cookie、私钥或生产配置提交到源码或发布到 npm。
|
|
56
|
+
- `AUTH_PROOF_DEVICE_SESSION_SECRET` 只用于服务端设备会话摘要,不得暴露给浏览器。
|
|
57
|
+
- 浏览器 P-256 私钥由 WebCrypto 创建并以不可导出 `CryptoKey` 保存到 IndexedDB。
|
|
58
|
+
- 包只提供协议能力;会话存储、Redis 原子操作、Guard 策略和业务路由由使用方负责。
|
|
59
|
+
|
|
60
|
+
## 发布内容
|
|
61
|
+
|
|
62
|
+
npm 包只发布 `core`、`browser`、`node` 三个入口的 JavaScript、类型声明、README 和 LICENSE。测试、脚本、`.env`、`.npmrc` 与 `node_modules` 不进入发布包。
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
package/browser.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type RequestProofPublicKey = {
|
|
2
|
+
kty: 'EC'
|
|
3
|
+
crv: 'P-256'
|
|
4
|
+
x: string
|
|
5
|
+
y: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type RequestProofRegistration = {
|
|
9
|
+
keyId: string
|
|
10
|
+
publicKey: RequestProofPublicKey
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type BrowserProofInput = {
|
|
14
|
+
token: string
|
|
15
|
+
audience: string
|
|
16
|
+
method: string
|
|
17
|
+
path: string
|
|
18
|
+
requestUri: string
|
|
19
|
+
body?: unknown
|
|
20
|
+
contentType?: unknown
|
|
21
|
+
hasBody?: boolean
|
|
22
|
+
requestId?: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type BrowserProofClient = {
|
|
26
|
+
reset(): Promise<void>
|
|
27
|
+
getRegistration(): Promise<RequestProofRegistration>
|
|
28
|
+
tryGetRegistration(): Promise<RequestProofRegistration | null>
|
|
29
|
+
createProof(input: BrowserProofInput): Promise<string>
|
|
30
|
+
tryCreateProof(input: BrowserProofInput): Promise<string>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function calculateKeyId(publicKey: JsonWebKey): Promise<string>
|
|
34
|
+
export function createProofRequestId(): string
|
|
35
|
+
export function createBrowserProofClient(options: {
|
|
36
|
+
databaseName: string
|
|
37
|
+
storeName?: string
|
|
38
|
+
recordId?: string
|
|
39
|
+
required: () => unknown
|
|
40
|
+
}): BrowserProofClient
|
package/browser.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
canonicalizeBody,
|
|
5
|
+
canonicalizeQuery,
|
|
6
|
+
isRequestProofRequired,
|
|
7
|
+
isRequestProofSetupError,
|
|
8
|
+
normalizePath,
|
|
9
|
+
} = require('./core')
|
|
10
|
+
|
|
11
|
+
const bytesToBase64Url = bytes => {
|
|
12
|
+
let binary = ''
|
|
13
|
+
bytes.forEach(byte => {
|
|
14
|
+
binary += String.fromCharCode(byte)
|
|
15
|
+
})
|
|
16
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const textToBase64Url = value => bytesToBase64Url(new TextEncoder().encode(value))
|
|
20
|
+
|
|
21
|
+
const getWebCrypto = () => {
|
|
22
|
+
const webCrypto = globalThis.crypto
|
|
23
|
+
if (!webCrypto?.subtle) throw new Error('request_proof_unsupported')
|
|
24
|
+
return webCrypto
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sha256Base64Url = async value => {
|
|
28
|
+
const digest = await getWebCrypto().subtle.digest('SHA-256', new TextEncoder().encode(value))
|
|
29
|
+
return bytesToBase64Url(new Uint8Array(digest))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const normalizePublicKey = publicKey => ({
|
|
33
|
+
kty: 'EC',
|
|
34
|
+
crv: 'P-256',
|
|
35
|
+
x: String(publicKey?.x || ''),
|
|
36
|
+
y: String(publicKey?.y || ''),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const calculateKeyId = publicKey =>
|
|
40
|
+
sha256Base64Url(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
crv: 'P-256',
|
|
43
|
+
kty: 'EC',
|
|
44
|
+
x: String(publicKey?.x || ''),
|
|
45
|
+
y: String(publicKey?.y || ''),
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const createProofRequestId = () => {
|
|
50
|
+
const webCrypto = getWebCrypto()
|
|
51
|
+
return typeof webCrypto.randomUUID === 'function'
|
|
52
|
+
? webCrypto.randomUUID()
|
|
53
|
+
: bytesToBase64Url(webCrypto.getRandomValues(new Uint8Array(24)))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const createBrowserProofClient = options => {
|
|
57
|
+
const databaseName = String(options?.databaseName || '').trim()
|
|
58
|
+
const storeName = String(options?.storeName || 'context').trim()
|
|
59
|
+
const recordId = String(options?.recordId || 'request-proof-key').trim()
|
|
60
|
+
const required = () => isRequestProofRequired(options?.required?.())
|
|
61
|
+
let proofKeyPromise = null
|
|
62
|
+
|
|
63
|
+
if (!databaseName || !storeName || !recordId) {
|
|
64
|
+
throw new Error('request_proof_storage_failed')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const openDatabase = () =>
|
|
68
|
+
new Promise((resolve, reject) => {
|
|
69
|
+
if (!globalThis.indexedDB) {
|
|
70
|
+
reject(new Error('request_proof_storage_unsupported'))
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const request = globalThis.indexedDB.open(databaseName, 1)
|
|
74
|
+
request.onupgradeneeded = () => {
|
|
75
|
+
const database = request.result
|
|
76
|
+
if (!database.objectStoreNames.contains(storeName)) {
|
|
77
|
+
database.createObjectStore(storeName, { keyPath: 'id' })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
request.onsuccess = () => resolve(request.result)
|
|
81
|
+
request.onerror = () => reject(new Error('request_proof_storage_failed'))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const readStoredKey = async () => {
|
|
85
|
+
const database = await openDatabase()
|
|
86
|
+
try {
|
|
87
|
+
return await new Promise((resolve, reject) => {
|
|
88
|
+
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get(recordId)
|
|
89
|
+
request.onsuccess = () => resolve(request.result || null)
|
|
90
|
+
request.onerror = () => reject(new Error('request_proof_read_failed'))
|
|
91
|
+
})
|
|
92
|
+
} finally {
|
|
93
|
+
database.close()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isStoredKeyValid = record => {
|
|
98
|
+
if (!record?.keyId || !record.publicKey?.x || !record.publicKey?.y || !record.privateKey) return false
|
|
99
|
+
const algorithm = record.privateKey.algorithm
|
|
100
|
+
return (
|
|
101
|
+
record.privateKey.type === 'private' &&
|
|
102
|
+
record.privateKey.extractable === false &&
|
|
103
|
+
algorithm?.name === 'ECDSA' &&
|
|
104
|
+
algorithm?.namedCurve === 'P-256' &&
|
|
105
|
+
Array.isArray(record.privateKey.usages) &&
|
|
106
|
+
record.privateKey.usages.includes('sign')
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const generateCandidate = async () => {
|
|
111
|
+
const webCrypto = getWebCrypto()
|
|
112
|
+
const keyPair = await webCrypto.subtle.generateKey(
|
|
113
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
114
|
+
false,
|
|
115
|
+
['sign', 'verify'],
|
|
116
|
+
)
|
|
117
|
+
const publicKey = normalizePublicKey(await webCrypto.subtle.exportKey('jwk', keyPair.publicKey))
|
|
118
|
+
return {
|
|
119
|
+
id: recordId,
|
|
120
|
+
keyId: await calculateKeyId(publicKey),
|
|
121
|
+
publicKey,
|
|
122
|
+
privateKey: keyPair.privateKey,
|
|
123
|
+
createdAt: new Date().toISOString(),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const storeIfAbsent = async candidate => {
|
|
128
|
+
const database = await openDatabase()
|
|
129
|
+
let selectedKey = candidate
|
|
130
|
+
try {
|
|
131
|
+
await new Promise((resolve, reject) => {
|
|
132
|
+
const transaction = database.transaction(storeName, 'readwrite')
|
|
133
|
+
const store = transaction.objectStore(storeName)
|
|
134
|
+
const readRequest = store.get(recordId)
|
|
135
|
+
readRequest.onsuccess = () => {
|
|
136
|
+
const storedKey = readRequest.result || null
|
|
137
|
+
if (isStoredKeyValid(storedKey)) selectedKey = storedKey
|
|
138
|
+
else store.put(candidate)
|
|
139
|
+
}
|
|
140
|
+
readRequest.onerror = () => reject(new Error('request_proof_read_failed'))
|
|
141
|
+
transaction.oncomplete = () => resolve()
|
|
142
|
+
transaction.onerror = () => reject(new Error('request_proof_write_failed'))
|
|
143
|
+
transaction.onabort = () => reject(new Error('request_proof_write_failed'))
|
|
144
|
+
})
|
|
145
|
+
return selectedKey
|
|
146
|
+
} finally {
|
|
147
|
+
database.close()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const getOrCreateKey = () => {
|
|
152
|
+
if (!proofKeyPromise) {
|
|
153
|
+
proofKeyPromise = (async () => {
|
|
154
|
+
const storedKey = await readStoredKey()
|
|
155
|
+
if (isStoredKeyValid(storedKey)) return storedKey
|
|
156
|
+
return storeIfAbsent(await generateCandidate())
|
|
157
|
+
})().catch(error => {
|
|
158
|
+
proofKeyPromise = null
|
|
159
|
+
throw error
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
return proofKeyPromise
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const reset = async () => {
|
|
166
|
+
const pendingKey = proofKeyPromise
|
|
167
|
+
proofKeyPromise = null
|
|
168
|
+
await pendingKey?.catch(() => undefined)
|
|
169
|
+
if (!globalThis.indexedDB) return
|
|
170
|
+
|
|
171
|
+
const database = await openDatabase()
|
|
172
|
+
try {
|
|
173
|
+
await new Promise((resolve, reject) => {
|
|
174
|
+
const transaction = database.transaction(storeName, 'readwrite')
|
|
175
|
+
transaction.objectStore(storeName).delete(recordId)
|
|
176
|
+
transaction.oncomplete = () => resolve()
|
|
177
|
+
transaction.onerror = () => reject(new Error('request_proof_reset_failed'))
|
|
178
|
+
transaction.onabort = () => reject(new Error('request_proof_reset_failed'))
|
|
179
|
+
})
|
|
180
|
+
} finally {
|
|
181
|
+
database.close()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const getRegistration = async () => {
|
|
186
|
+
const key = await getOrCreateKey()
|
|
187
|
+
return { keyId: key.keyId, publicKey: key.publicKey }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const runtimeUnavailable = () =>
|
|
191
|
+
(typeof window !== 'undefined' && window.isSecureContext === false) ||
|
|
192
|
+
!globalThis.crypto?.subtle ||
|
|
193
|
+
!globalThis.indexedDB
|
|
194
|
+
|
|
195
|
+
const tryGetRegistration = async () => {
|
|
196
|
+
if (!required() && runtimeUnavailable()) return null
|
|
197
|
+
try {
|
|
198
|
+
return await getRegistration()
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (!required() && isRequestProofSetupError(error)) return null
|
|
201
|
+
throw error
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const createProof = async input => {
|
|
206
|
+
const token = String(input?.token || '').trim()
|
|
207
|
+
if (!token) return ''
|
|
208
|
+
const key = await getOrCreateKey()
|
|
209
|
+
const requestId = String(input?.requestId || '').trim()
|
|
210
|
+
const [ath, qsh, bth] = await Promise.all([
|
|
211
|
+
sha256Base64Url(token),
|
|
212
|
+
sha256Base64Url(canonicalizeQuery(input?.requestUri)),
|
|
213
|
+
sha256Base64Url(canonicalizeBody(input?.body, input?.contentType, input?.hasBody)),
|
|
214
|
+
])
|
|
215
|
+
const header = { alg: 'ES256', kid: key.keyId, typ: 'dpop+jwt' }
|
|
216
|
+
const payload = {
|
|
217
|
+
ath,
|
|
218
|
+
aud: String(input?.audience || ''),
|
|
219
|
+
htm: String(input?.method || 'GET').toUpperCase(),
|
|
220
|
+
htu: normalizePath(input?.path),
|
|
221
|
+
qsh,
|
|
222
|
+
bth,
|
|
223
|
+
rid: requestId,
|
|
224
|
+
iat: Math.floor(Date.now() / 1000),
|
|
225
|
+
jti: createProofRequestId(),
|
|
226
|
+
}
|
|
227
|
+
const signingInput = `${textToBase64Url(JSON.stringify(header))}.${textToBase64Url(JSON.stringify(payload))}`
|
|
228
|
+
let signature
|
|
229
|
+
try {
|
|
230
|
+
signature = await getWebCrypto().subtle.sign(
|
|
231
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
232
|
+
key.privateKey,
|
|
233
|
+
new TextEncoder().encode(signingInput),
|
|
234
|
+
)
|
|
235
|
+
} catch {
|
|
236
|
+
throw new Error('request_proof_sign_failed')
|
|
237
|
+
}
|
|
238
|
+
return `${signingInput}.${bytesToBase64Url(new Uint8Array(signature))}`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const tryCreateProof = async input => {
|
|
242
|
+
if (!String(input?.token || '').trim()) return ''
|
|
243
|
+
if (!required() && runtimeUnavailable()) return ''
|
|
244
|
+
try {
|
|
245
|
+
return await createProof(input)
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (!required() && isRequestProofSetupError(error)) return ''
|
|
248
|
+
throw error
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { createProof, getRegistration, reset, tryCreateProof, tryGetRegistration }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { calculateKeyId, createBrowserProofClient, createProofRequestId }
|
package/core.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const REQUEST_PROOF_HEADER: 'x-ctx-proof'
|
|
2
|
+
export const AUTH_CENTER_PROOF_AUDIENCE: 'auth-center'
|
|
3
|
+
export const ODM_REQUEST_PROOF_AUDIENCE: 'uweb-odm'
|
|
4
|
+
export const DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS: number
|
|
5
|
+
export const REQUEST_PROOF_ERROR_CODES: Readonly<{
|
|
6
|
+
timeMismatch: 'E4601'
|
|
7
|
+
requestExpired: 'E4602'
|
|
8
|
+
requestUnverifiable: 'E4603'
|
|
9
|
+
sessionMismatch: 'E4604'
|
|
10
|
+
requestInvalid: 'E4605'
|
|
11
|
+
serviceUnavailable: 'E503'
|
|
12
|
+
}>
|
|
13
|
+
|
|
14
|
+
export class RequestProofValidationError extends Error {
|
|
15
|
+
readonly code: string
|
|
16
|
+
constructor(code: string)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function canonicalizeQuery(value: unknown): string
|
|
20
|
+
export function canonicalizeBody(body: unknown, contentType?: unknown, hasBody?: boolean): string
|
|
21
|
+
export function normalizeJsonValue(value: unknown): unknown
|
|
22
|
+
export function normalizePath(value: unknown): string
|
|
23
|
+
export function normalizeProofMode(value: unknown): 'off' | 'shadow' | 'enforce'
|
|
24
|
+
export function stableJson(value: unknown): string
|
|
25
|
+
export function isRequestProofRequired(value: unknown): boolean
|
|
26
|
+
export function isRequestProofSetupError(error: unknown): boolean
|
|
27
|
+
export function getRequestProofErrorCode(
|
|
28
|
+
reason: string,
|
|
29
|
+
): (typeof REQUEST_PROOF_ERROR_CODES)[keyof typeof REQUEST_PROOF_ERROR_CODES]
|
package/core.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const REQUEST_PROOF_HEADER = 'x-ctx-proof'
|
|
4
|
+
const AUTH_CENTER_PROOF_AUDIENCE = 'auth-center'
|
|
5
|
+
const ODM_REQUEST_PROOF_AUDIENCE = 'uweb-odm'
|
|
6
|
+
const DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS = 2 * 60 * 60
|
|
7
|
+
|
|
8
|
+
const REQUEST_PROOF_ERROR_CODES = Object.freeze({
|
|
9
|
+
timeMismatch: 'E4601',
|
|
10
|
+
requestExpired: 'E4602',
|
|
11
|
+
requestUnverifiable: 'E4603',
|
|
12
|
+
sessionMismatch: 'E4604',
|
|
13
|
+
requestInvalid: 'E4605',
|
|
14
|
+
serviceUnavailable: 'E503',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
class RequestProofValidationError extends Error {
|
|
18
|
+
constructor(code) {
|
|
19
|
+
super(code)
|
|
20
|
+
this.name = 'RequestProofValidationError'
|
|
21
|
+
this.code = code
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0)
|
|
26
|
+
|
|
27
|
+
const normalizePath = value => {
|
|
28
|
+
const rawValue = String(value || '').trim()
|
|
29
|
+
if (!rawValue) return ''
|
|
30
|
+
|
|
31
|
+
let path = rawValue
|
|
32
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(rawValue)) {
|
|
33
|
+
try {
|
|
34
|
+
path = new URL(rawValue).pathname
|
|
35
|
+
} catch {
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const withoutQuery = path.split(/[?#]/)[0]
|
|
41
|
+
const withLeadingSlash = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`
|
|
42
|
+
return withLeadingSlash.replace(/\/{2,}/g, '/') || '/'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const canonicalizeQuery = value => {
|
|
46
|
+
const rawValue = String(value || '').trim()
|
|
47
|
+
if (!rawValue) return ''
|
|
48
|
+
|
|
49
|
+
let query = ''
|
|
50
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(rawValue)) {
|
|
51
|
+
try {
|
|
52
|
+
query = new URL(rawValue).search.slice(1)
|
|
53
|
+
} catch {
|
|
54
|
+
return ''
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const withoutFragment = rawValue.split('#')[0]
|
|
58
|
+
const queryIndex = withoutFragment.indexOf('?')
|
|
59
|
+
query = queryIndex >= 0 ? withoutFragment.slice(queryIndex + 1) : ''
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Array.from(new URLSearchParams(query).entries())
|
|
63
|
+
.sort(([leftKey, leftValue], [rightKey, rightValue]) => {
|
|
64
|
+
const keyResult = compareText(leftKey, rightKey)
|
|
65
|
+
return keyResult || compareText(leftValue, rightValue)
|
|
66
|
+
})
|
|
67
|
+
.map(([key, entryValue]) => `${encodeURIComponent(key)}=${encodeURIComponent(entryValue)}`)
|
|
68
|
+
.join('&')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const normalizeJsonValue = value => {
|
|
72
|
+
if (value === null) return null
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return value.map(item =>
|
|
75
|
+
item === undefined || typeof item === 'function' || typeof item === 'symbol'
|
|
76
|
+
? null
|
|
77
|
+
: normalizeJsonValue(item),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
81
|
+
if (typeof value !== 'object') return value
|
|
82
|
+
if (typeof value.toJSON === 'function') return normalizeJsonValue(value.toJSON())
|
|
83
|
+
|
|
84
|
+
return Object.keys(value)
|
|
85
|
+
.sort(compareText)
|
|
86
|
+
.reduce((result, key) => {
|
|
87
|
+
const item = value[key]
|
|
88
|
+
if (item !== undefined && typeof item !== 'function' && typeof item !== 'symbol') {
|
|
89
|
+
result[key] = normalizeJsonValue(item)
|
|
90
|
+
}
|
|
91
|
+
return result
|
|
92
|
+
}, {})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const stableJson = value => JSON.stringify(normalizeJsonValue(value)) || ''
|
|
96
|
+
|
|
97
|
+
const formEntriesToObject = entries => {
|
|
98
|
+
const result = {}
|
|
99
|
+
for (const [key, value] of entries) {
|
|
100
|
+
const current = result[key]
|
|
101
|
+
result[key] =
|
|
102
|
+
current === undefined
|
|
103
|
+
? value
|
|
104
|
+
: Array.isArray(current)
|
|
105
|
+
? [...current, value]
|
|
106
|
+
: [current, value]
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const isBinaryBody = body => {
|
|
112
|
+
if (typeof Buffer !== 'undefined' && typeof Buffer.isBuffer === 'function' && Buffer.isBuffer(body)) {
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) return true
|
|
116
|
+
if (typeof ArrayBuffer !== 'undefined') {
|
|
117
|
+
return body instanceof ArrayBuffer || ArrayBuffer.isView(body)
|
|
118
|
+
}
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const canonicalizeBody = (body, contentType, hasBody) => {
|
|
123
|
+
const normalizedContentType = String(contentType || '').toLowerCase()
|
|
124
|
+
if (hasBody === false) return 'none'
|
|
125
|
+
if (normalizedContentType.includes('multipart/form-data')) return 'multipart'
|
|
126
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) return 'multipart'
|
|
127
|
+
if (body === undefined || body === null || body === '') return 'none'
|
|
128
|
+
if (isBinaryBody(body)) return 'binary'
|
|
129
|
+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
130
|
+
return `form:${stableJson(formEntriesToObject(body.entries()))}`
|
|
131
|
+
}
|
|
132
|
+
if (typeof body === 'string') {
|
|
133
|
+
if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
|
|
134
|
+
return `form:${stableJson(formEntriesToObject(new URLSearchParams(body).entries()))}`
|
|
135
|
+
}
|
|
136
|
+
if (normalizedContentType.includes('json')) {
|
|
137
|
+
try {
|
|
138
|
+
return `json:${stableJson(JSON.parse(body))}`
|
|
139
|
+
} catch {
|
|
140
|
+
return `text:${body}`
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return `text:${body}`
|
|
144
|
+
}
|
|
145
|
+
if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
|
|
146
|
+
return `form:${stableJson(body)}`
|
|
147
|
+
}
|
|
148
|
+
return `json:${stableJson(body)}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const isRequestProofRequired = value => String(value ?? '').trim() !== '0'
|
|
152
|
+
|
|
153
|
+
const normalizeProofMode = value => {
|
|
154
|
+
const mode = String(value ?? '').trim().toLowerCase()
|
|
155
|
+
if (mode === '0' || mode === 'off') return 'off'
|
|
156
|
+
if (mode === '1' || mode === 'shadow') return 'shadow'
|
|
157
|
+
return 'enforce'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isRequestProofSetupError = error => {
|
|
161
|
+
const message = error instanceof Error ? error.message : String(error || '')
|
|
162
|
+
return /^request_proof_(?:unsupported|storage_unsupported|storage_failed|read_failed|write_failed|reset_failed|sign_failed)$/.test(
|
|
163
|
+
message,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const getRequestProofErrorCode = reason => {
|
|
168
|
+
if (reason === 'proof_expired') return REQUEST_PROOF_ERROR_CODES.timeMismatch
|
|
169
|
+
if (reason === 'proof_replayed') return REQUEST_PROOF_ERROR_CODES.requestExpired
|
|
170
|
+
if (
|
|
171
|
+
[
|
|
172
|
+
'missing_proof',
|
|
173
|
+
'missing_proof_binding',
|
|
174
|
+
'missing_bound_proof',
|
|
175
|
+
'missing_sso_source_proof',
|
|
176
|
+
'invalid_proof_key',
|
|
177
|
+
].includes(reason)
|
|
178
|
+
) {
|
|
179
|
+
return REQUEST_PROOF_ERROR_CODES.requestUnverifiable
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
[
|
|
183
|
+
'missing_user',
|
|
184
|
+
'missing_token',
|
|
185
|
+
'missing_device_cookie',
|
|
186
|
+
'missing_binding',
|
|
187
|
+
'missing_session_context',
|
|
188
|
+
'token_mismatch',
|
|
189
|
+
'session_mismatch',
|
|
190
|
+
'session_id_mismatch',
|
|
191
|
+
'missing_sso_source_binding',
|
|
192
|
+
'sso_source_device_mismatch',
|
|
193
|
+
].includes(reason)
|
|
194
|
+
) {
|
|
195
|
+
return REQUEST_PROOF_ERROR_CODES.sessionMismatch
|
|
196
|
+
}
|
|
197
|
+
if (reason === 'missing_device_secret' || reason === 'verification_failed') {
|
|
198
|
+
return REQUEST_PROOF_ERROR_CODES.serviceUnavailable
|
|
199
|
+
}
|
|
200
|
+
return REQUEST_PROOF_ERROR_CODES.requestInvalid
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
AUTH_CENTER_PROOF_AUDIENCE,
|
|
205
|
+
DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
|
|
206
|
+
ODM_REQUEST_PROOF_AUDIENCE,
|
|
207
|
+
REQUEST_PROOF_ERROR_CODES,
|
|
208
|
+
REQUEST_PROOF_HEADER,
|
|
209
|
+
RequestProofValidationError,
|
|
210
|
+
canonicalizeBody,
|
|
211
|
+
canonicalizeQuery,
|
|
212
|
+
getRequestProofErrorCode,
|
|
213
|
+
isRequestProofRequired,
|
|
214
|
+
isRequestProofSetupError,
|
|
215
|
+
normalizeJsonValue,
|
|
216
|
+
normalizePath,
|
|
217
|
+
normalizeProofMode,
|
|
218
|
+
stableJson,
|
|
219
|
+
}
|
package/node.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type RequestProofPublicKey = {
|
|
2
|
+
kty: 'EC'
|
|
3
|
+
crv: 'P-256'
|
|
4
|
+
x: string
|
|
5
|
+
y: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type NormalizedRequestProofKey = {
|
|
9
|
+
keyId: string
|
|
10
|
+
publicKey: RequestProofPublicKey
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RequestProofTimeWarning = {
|
|
14
|
+
reason: 'proof_expired'
|
|
15
|
+
issuedAt: number | null
|
|
16
|
+
serverNow: number
|
|
17
|
+
diffSeconds: number | null
|
|
18
|
+
toleranceSeconds: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type VerifyRequestProofOptions = {
|
|
22
|
+
proof: string
|
|
23
|
+
publicKey: RequestProofPublicKey
|
|
24
|
+
keyId: string
|
|
25
|
+
token: string
|
|
26
|
+
method: string
|
|
27
|
+
path: string
|
|
28
|
+
audience: string
|
|
29
|
+
timeToleranceSeconds?: number
|
|
30
|
+
body?: unknown
|
|
31
|
+
contentType?: unknown
|
|
32
|
+
hasBody?: boolean
|
|
33
|
+
requestId?: unknown
|
|
34
|
+
requireRequestBindings?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ProofServerMode = 'off' | 'shadow' | 'enforce'
|
|
38
|
+
|
|
39
|
+
export type ProofServerConfig = {
|
|
40
|
+
mode: ProofServerMode
|
|
41
|
+
deviceSessionSecret: string
|
|
42
|
+
deviceSessionTtlSeconds: number
|
|
43
|
+
timeToleranceSeconds: number
|
|
44
|
+
shadowBlockMissingProof: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const REQUEST_PROOF_REASON_TEXT: Readonly<Record<string, string>>
|
|
48
|
+
export function getRequestProofReasonText(reason: string): string
|
|
49
|
+
|
|
50
|
+
export function hasTransferredRequestBody(options: {
|
|
51
|
+
body?: unknown
|
|
52
|
+
contentLength?: unknown
|
|
53
|
+
transferEncoding?: unknown
|
|
54
|
+
}): boolean
|
|
55
|
+
export function calculateRequestProofBindings(options: {
|
|
56
|
+
path: unknown
|
|
57
|
+
body?: unknown
|
|
58
|
+
contentType?: unknown
|
|
59
|
+
hasBody?: boolean
|
|
60
|
+
requestId?: unknown
|
|
61
|
+
}): { qsh: string; bth: string; rid: string }
|
|
62
|
+
export function normalizeRequestProofKey(value: unknown): Promise<NormalizedRequestProofKey | null>
|
|
63
|
+
export function getRequestProofKeyId(proofValue: string): string
|
|
64
|
+
export function verifyRequestProof(options: VerifyRequestProofOptions): Promise<{
|
|
65
|
+
jti: string
|
|
66
|
+
timeWarning: RequestProofTimeWarning | null
|
|
67
|
+
}>
|
|
68
|
+
export function normalizeProofMode(value: unknown): ProofServerMode
|
|
69
|
+
export function resolveProofServerConfig(options?: {
|
|
70
|
+
env?: Record<string, string | undefined>
|
|
71
|
+
mode?: string | number
|
|
72
|
+
deviceSessionSecret?: string
|
|
73
|
+
deviceSessionTtlSeconds?: number
|
|
74
|
+
timeToleranceSeconds?: number
|
|
75
|
+
shadowBlockMissingProof?: boolean
|
|
76
|
+
}): ProofServerConfig
|
|
77
|
+
|
|
78
|
+
export { RequestProofValidationError } from './core'
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@travelclw/proof-protocol",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-independent browser and Node.js request Proof protocol",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "travelclw",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://gitee.com/bubbles_4/travelclw.git",
|
|
10
|
+
"directory": "packages/proof-protocol"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://gitee.com/bubbles_4/travelclw/tree/main/packages/proof-protocol#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://gitee.com/bubbles_4/travelclw/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"proof-of-possession",
|
|
18
|
+
"request-signing",
|
|
19
|
+
"webcrypto",
|
|
20
|
+
"authentication",
|
|
21
|
+
"security"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"*.js",
|
|
25
|
+
"*.d.ts",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"type": "commonjs",
|
|
30
|
+
"main": "./core.js",
|
|
31
|
+
"types": "./core.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./core.d.ts",
|
|
35
|
+
"default": "./core.js"
|
|
36
|
+
},
|
|
37
|
+
"./browser": {
|
|
38
|
+
"types": "./browser.d.ts",
|
|
39
|
+
"default": "./browser.js"
|
|
40
|
+
},
|
|
41
|
+
"./node": {
|
|
42
|
+
"types": "./node.d.ts",
|
|
43
|
+
"default": "./server.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"jose": ">=5 <6"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"jose": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"jose": "^5.10.0"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public",
|
|
62
|
+
"registry": "https://registry.npmjs.org/"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"test": "node --test",
|
|
66
|
+
"verify:publish": "node scripts/verify-publish.js",
|
|
67
|
+
"prepublishOnly": "npm run verify:publish && npm test"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { createHash, timingSafeEqual } = require('node:crypto')
|
|
4
|
+
const { calculateJwkThumbprint, decodeProtectedHeader, importJWK, jwtVerify } = require('jose')
|
|
5
|
+
const {
|
|
6
|
+
DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
|
|
7
|
+
RequestProofValidationError,
|
|
8
|
+
canonicalizeBody,
|
|
9
|
+
canonicalizeQuery,
|
|
10
|
+
normalizePath,
|
|
11
|
+
normalizeProofMode,
|
|
12
|
+
} = require('./core')
|
|
13
|
+
|
|
14
|
+
const REQUEST_PROOF_REASON_TEXT = Object.freeze({
|
|
15
|
+
invalid_proof_key: '浏览器提交的 proof 公钥无效或缺失',
|
|
16
|
+
missing_user: 'JWT 中缺少用户名,无法定位账号会话',
|
|
17
|
+
missing_token: '请求缺少 Authorization Bearer token',
|
|
18
|
+
missing_proof: '请求缺少浏览器 proof',
|
|
19
|
+
missing_device_cookie: '请求缺少设备 Cookie,无法定位浏览器设备',
|
|
20
|
+
missing_device_secret: '未配置设备会话摘要密钥,无法计算设备摘要',
|
|
21
|
+
missing_binding: 'Redis 中不存在当前账号与设备 Cookie 的会话绑定',
|
|
22
|
+
missing_session_context: '缺少当前 token 对应的设备会话上下文',
|
|
23
|
+
missing_proof_binding: '设备会话尚未绑定浏览器 proof 公钥',
|
|
24
|
+
token_mismatch: '当前 token 与设备会话绑定不一致',
|
|
25
|
+
session_mismatch: 'JWT sessionId 与设备会话绑定不一致',
|
|
26
|
+
session_id_mismatch: '登录会话 ID 与设备会话绑定不一致',
|
|
27
|
+
missing_sso_source_binding: '一次性 SSO 凭证缺少可信源设备绑定',
|
|
28
|
+
missing_sso_source_proof: '一次性 SSO 凭证缺少源浏览器 Proof 授权',
|
|
29
|
+
sso_source_device_mismatch: '一次性 SSO 凭证与当前设备不匹配',
|
|
30
|
+
proof_key_mismatch: '当前浏览器 proof 公钥与已绑定公钥不一致',
|
|
31
|
+
proof_replayed: '请求 proof 的 jti 已使用,疑似重放请求',
|
|
32
|
+
invalid_proof_format: '请求 proof 格式不是合法 JWT',
|
|
33
|
+
invalid_proof_header: '请求 proof 头部算法或类型不符合要求',
|
|
34
|
+
proof_expired: '请求 proof 时间与服务器时间偏差超过允许窗口',
|
|
35
|
+
invalid_proof_jti: '请求 proof 缺少有效 jti',
|
|
36
|
+
proof_method_mismatch: '请求 proof 绑定的 HTTP 方法不匹配',
|
|
37
|
+
proof_path_mismatch: '请求 proof 绑定的接口路径不匹配',
|
|
38
|
+
proof_token_mismatch: '请求 proof 绑定的 token 摘要不匹配',
|
|
39
|
+
proof_query_mismatch: '请求 proof 绑定的查询参数不匹配',
|
|
40
|
+
proof_body_mismatch: '请求 proof 绑定的请求内容不匹配',
|
|
41
|
+
proof_request_id_mismatch: '请求 proof 绑定的请求 ID 不匹配',
|
|
42
|
+
missing_proof_query_binding: '请求 proof 缺少查询参数绑定',
|
|
43
|
+
missing_proof_body_binding: '请求 proof 缺少请求内容绑定',
|
|
44
|
+
missing_proof_request_id_binding: '请求 proof 缺少请求 ID 绑定',
|
|
45
|
+
invalid_proof_signature: '请求 proof 签名验签失败',
|
|
46
|
+
proof_missing_or_invalid: '缺少 proof 或 proof 公钥无效',
|
|
47
|
+
missing_bound_proof: '当前浏览器会话未绑定请求 proof 公钥',
|
|
48
|
+
user_agent_changed: '请求 User-Agent 与登录绑定时不同',
|
|
49
|
+
accept_language_changed: '请求 Accept-Language 与登录绑定时不同',
|
|
50
|
+
verification_failed: '请求 proof 验证过程异常',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const getRequestProofReasonText = reason =>
|
|
54
|
+
REQUEST_PROOF_REASON_TEXT[String(reason || '')] || '未知请求 Proof 校验失败原因'
|
|
55
|
+
|
|
56
|
+
const safeEqual = (left, right) => {
|
|
57
|
+
const leftBuffer = Buffer.from(left)
|
|
58
|
+
const rightBuffer = Buffer.from(right)
|
|
59
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hashBindingValue = value => createHash('sha256').update(value).digest('base64url')
|
|
63
|
+
|
|
64
|
+
const hasTransferredRequestBody = options => {
|
|
65
|
+
const rawContentLength = Array.isArray(options?.contentLength)
|
|
66
|
+
? options.contentLength[0]
|
|
67
|
+
: options?.contentLength
|
|
68
|
+
const contentLengthText = String(rawContentLength ?? '').trim()
|
|
69
|
+
if (contentLengthText) {
|
|
70
|
+
const contentLength = Number(contentLengthText)
|
|
71
|
+
if (Number.isFinite(contentLength) && contentLength >= 0) return contentLength > 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rawTransferEncoding = Array.isArray(options?.transferEncoding)
|
|
75
|
+
? options.transferEncoding[0]
|
|
76
|
+
: options?.transferEncoding
|
|
77
|
+
if (String(rawTransferEncoding ?? '').trim()) return true
|
|
78
|
+
|
|
79
|
+
const body = options?.body
|
|
80
|
+
if (body === undefined || body === null || body === '') return false
|
|
81
|
+
if (
|
|
82
|
+
typeof body === 'object' &&
|
|
83
|
+
!Array.isArray(body) &&
|
|
84
|
+
!Buffer.isBuffer(body) &&
|
|
85
|
+
Object.keys(body).length === 0
|
|
86
|
+
) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const calculateRequestProofBindings = options => ({
|
|
93
|
+
qsh: hashBindingValue(canonicalizeQuery(options?.path)),
|
|
94
|
+
bth: hashBindingValue(canonicalizeBody(options?.body, options?.contentType, options?.hasBody)),
|
|
95
|
+
rid: String(options?.requestId || '').trim(),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const normalizePublicKey = value => {
|
|
99
|
+
const x = String(value?.x || '').trim()
|
|
100
|
+
const y = String(value?.y || '').trim()
|
|
101
|
+
const isCoordinate = coordinate =>
|
|
102
|
+
coordinate.length >= 40 && coordinate.length <= 60 && /^[A-Za-z0-9_-]+$/.test(coordinate)
|
|
103
|
+
|
|
104
|
+
if (value?.kty !== 'EC' || value?.crv !== 'P-256' || !isCoordinate(x) || !isCoordinate(y)) {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
return { kty: 'EC', crv: 'P-256', x, y }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const normalizeRequestProofKey = async value => {
|
|
111
|
+
const publicKey = normalizePublicKey(value?.publicKey)
|
|
112
|
+
if (!publicKey) return null
|
|
113
|
+
|
|
114
|
+
const keyId = await calculateJwkThumbprint(publicKey, 'sha256')
|
|
115
|
+
const suppliedKeyId = String(value?.keyId || '').trim()
|
|
116
|
+
if (suppliedKeyId && !safeEqual(suppliedKeyId, keyId)) return null
|
|
117
|
+
return { keyId, publicKey }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const hashToken = token => createHash('sha256').update(token).digest('base64url')
|
|
121
|
+
|
|
122
|
+
const getRequestProofKeyId = proofValue => {
|
|
123
|
+
const proof = String(proofValue || '').trim()
|
|
124
|
+
if (!proof) throw new RequestProofValidationError('missing_proof')
|
|
125
|
+
if (proof.length < 64 || proof.length > 4096) {
|
|
126
|
+
throw new RequestProofValidationError('invalid_proof_format')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let protectedHeader
|
|
130
|
+
try {
|
|
131
|
+
protectedHeader = decodeProtectedHeader(proof)
|
|
132
|
+
} catch {
|
|
133
|
+
throw new RequestProofValidationError('invalid_proof_header')
|
|
134
|
+
}
|
|
135
|
+
if (protectedHeader.typ !== 'dpop+jwt' || protectedHeader.alg !== 'ES256') {
|
|
136
|
+
throw new RequestProofValidationError('invalid_proof_header')
|
|
137
|
+
}
|
|
138
|
+
const keyId = String(protectedHeader.kid || '').trim()
|
|
139
|
+
if (!keyId) throw new RequestProofValidationError('invalid_proof_header')
|
|
140
|
+
return keyId
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const verifyRequestProof = async options => {
|
|
144
|
+
const proof = String(options?.proof || '').trim()
|
|
145
|
+
if (!proof) throw new RequestProofValidationError('missing_proof')
|
|
146
|
+
if (proof.length < 64 || proof.length > 4096) {
|
|
147
|
+
throw new RequestProofValidationError('invalid_proof_format')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const keyId = getRequestProofKeyId(proof)
|
|
151
|
+
if (!safeEqual(keyId, options.keyId)) {
|
|
152
|
+
throw new RequestProofValidationError('proof_key_mismatch')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const verificationKey = await importJWK(options.publicKey, 'ES256')
|
|
157
|
+
const { payload } = await jwtVerify(proof, verificationKey, {
|
|
158
|
+
algorithms: ['ES256'],
|
|
159
|
+
audience: options.audience,
|
|
160
|
+
typ: 'dpop+jwt',
|
|
161
|
+
})
|
|
162
|
+
const now = Math.floor(Date.now() / 1000)
|
|
163
|
+
const timeToleranceSeconds =
|
|
164
|
+
options.timeToleranceSeconds || DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS
|
|
165
|
+
const issuedAt = Number(payload.iat)
|
|
166
|
+
const timeWarning =
|
|
167
|
+
!Number.isInteger(issuedAt) || Math.abs(issuedAt - now) > timeToleranceSeconds
|
|
168
|
+
? {
|
|
169
|
+
reason: 'proof_expired',
|
|
170
|
+
issuedAt: Number.isInteger(issuedAt) ? issuedAt : null,
|
|
171
|
+
serverNow: now,
|
|
172
|
+
diffSeconds: Number.isInteger(issuedAt) ? issuedAt - now : null,
|
|
173
|
+
toleranceSeconds: timeToleranceSeconds,
|
|
174
|
+
}
|
|
175
|
+
: null
|
|
176
|
+
|
|
177
|
+
const jti = String(payload.jti || '').trim()
|
|
178
|
+
if (jti.length < 16 || jti.length > 200) {
|
|
179
|
+
throw new RequestProofValidationError('invalid_proof_jti')
|
|
180
|
+
}
|
|
181
|
+
if (String(payload.htm || '').toUpperCase() !== String(options.method || '').toUpperCase()) {
|
|
182
|
+
throw new RequestProofValidationError('proof_method_mismatch')
|
|
183
|
+
}
|
|
184
|
+
if (normalizePath(payload.htu) !== normalizePath(options.path)) {
|
|
185
|
+
throw new RequestProofValidationError('proof_path_mismatch')
|
|
186
|
+
}
|
|
187
|
+
if (!safeEqual(String(payload.ath || ''), hashToken(options.token))) {
|
|
188
|
+
throw new RequestProofValidationError('proof_token_mismatch')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const expectedBindings = calculateRequestProofBindings(options)
|
|
192
|
+
const queryHash = String(payload.qsh || '').trim()
|
|
193
|
+
if (options.requireRequestBindings && !queryHash) {
|
|
194
|
+
throw new RequestProofValidationError('missing_proof_query_binding')
|
|
195
|
+
}
|
|
196
|
+
if (queryHash && !safeEqual(queryHash, expectedBindings.qsh)) {
|
|
197
|
+
throw new RequestProofValidationError('proof_query_mismatch')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const bodyHash = String(payload.bth || '').trim()
|
|
201
|
+
if (options.requireRequestBindings && !bodyHash) {
|
|
202
|
+
throw new RequestProofValidationError('missing_proof_body_binding')
|
|
203
|
+
}
|
|
204
|
+
if (bodyHash && !safeEqual(bodyHash, expectedBindings.bth)) {
|
|
205
|
+
throw new RequestProofValidationError('proof_body_mismatch')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const requestId = String(payload.rid || '').trim()
|
|
209
|
+
if (options.requireRequestBindings && !requestId) {
|
|
210
|
+
throw new RequestProofValidationError('missing_proof_request_id_binding')
|
|
211
|
+
}
|
|
212
|
+
if (requestId && !safeEqual(requestId, expectedBindings.rid)) {
|
|
213
|
+
throw new RequestProofValidationError('proof_request_id_mismatch')
|
|
214
|
+
}
|
|
215
|
+
return { jti, timeWarning }
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error instanceof RequestProofValidationError) throw error
|
|
218
|
+
throw new RequestProofValidationError('invalid_proof_signature')
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const readOverride = (env, name, fallback) => {
|
|
223
|
+
const value = env?.[name]
|
|
224
|
+
return value !== undefined && String(value).trim() !== '' ? value : fallback
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const integerSetting = (value, fallback, name, minimum, maximum) => {
|
|
228
|
+
if (value === undefined || String(value).trim() === '') return fallback
|
|
229
|
+
const parsed = Number(value)
|
|
230
|
+
if (
|
|
231
|
+
!Number.isInteger(parsed) ||
|
|
232
|
+
parsed < minimum ||
|
|
233
|
+
(maximum !== undefined && parsed > maximum)
|
|
234
|
+
) {
|
|
235
|
+
const range = maximum === undefined ? `at least ${minimum}` : `between ${minimum} and ${maximum}`
|
|
236
|
+
throw new Error(`${name} must be an integer ${range}`)
|
|
237
|
+
}
|
|
238
|
+
return parsed
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const booleanSetting = (value, fallback, name) => {
|
|
242
|
+
if (value === undefined || String(value).trim() === '') return fallback
|
|
243
|
+
const normalized = String(value).trim()
|
|
244
|
+
if (normalized !== '0' && normalized !== '1') {
|
|
245
|
+
throw new Error(`${name} must be 0 or 1`)
|
|
246
|
+
}
|
|
247
|
+
return normalized === '1'
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const resolveProofServerConfig = options => {
|
|
251
|
+
const env = options?.env ?? (typeof process !== 'undefined' ? process.env : {})
|
|
252
|
+
const mode = normalizeProofMode(readOverride(env, 'AUTH_PROOF_MODE', options?.mode))
|
|
253
|
+
const deviceSessionSecret = String(
|
|
254
|
+
readOverride(env, 'AUTH_PROOF_DEVICE_SESSION_SECRET', options?.deviceSessionSecret) || '',
|
|
255
|
+
).trim()
|
|
256
|
+
const deviceSessionTtlSeconds = integerSetting(
|
|
257
|
+
readOverride(
|
|
258
|
+
env,
|
|
259
|
+
'AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS',
|
|
260
|
+
options?.deviceSessionTtlSeconds,
|
|
261
|
+
),
|
|
262
|
+
12 * 60 * 60,
|
|
263
|
+
'AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS',
|
|
264
|
+
1,
|
|
265
|
+
)
|
|
266
|
+
const timeToleranceSeconds = integerSetting(
|
|
267
|
+
readOverride(env, 'AUTH_PROOF_TIME_TOLERANCE_SECONDS', options?.timeToleranceSeconds),
|
|
268
|
+
DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
|
|
269
|
+
'AUTH_PROOF_TIME_TOLERANCE_SECONDS',
|
|
270
|
+
30,
|
|
271
|
+
DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
|
|
272
|
+
)
|
|
273
|
+
const shadowBlockMissingProof = booleanSetting(
|
|
274
|
+
readOverride(
|
|
275
|
+
env,
|
|
276
|
+
'AUTH_PROOF_SHADOW_BLOCK_MISSING_PROOF',
|
|
277
|
+
options?.shadowBlockMissingProof ? '1' : '0',
|
|
278
|
+
),
|
|
279
|
+
false,
|
|
280
|
+
'AUTH_PROOF_SHADOW_BLOCK_MISSING_PROOF',
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if (mode !== 'off' && deviceSessionSecret.length < 32) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
'AUTH_PROOF_DEVICE_SESSION_SECRET is required when AUTH_PROOF_MODE is shadow or enforce and must contain at least 32 characters',
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
mode,
|
|
290
|
+
deviceSessionSecret,
|
|
291
|
+
deviceSessionTtlSeconds,
|
|
292
|
+
timeToleranceSeconds,
|
|
293
|
+
shadowBlockMissingProof,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = {
|
|
298
|
+
REQUEST_PROOF_REASON_TEXT,
|
|
299
|
+
RequestProofValidationError,
|
|
300
|
+
calculateRequestProofBindings,
|
|
301
|
+
getRequestProofKeyId,
|
|
302
|
+
getRequestProofReasonText,
|
|
303
|
+
hasTransferredRequestBody,
|
|
304
|
+
normalizeProofMode,
|
|
305
|
+
normalizeRequestProofKey,
|
|
306
|
+
resolveProofServerConfig,
|
|
307
|
+
verifyRequestProof,
|
|
308
|
+
}
|