@travelclw/proof-protocol 0.1.2 → 0.2.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 +81 -69
- package/browser.d.ts +42 -40
- package/browser.js +342 -306
- package/core.js +221 -219
- package/node.d.ts +80 -76
- package/package.json +1 -10
- package/server.js +316 -288
package/server.js
CHANGED
|
@@ -1,288 +1,316 @@
|
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
throw new RequestProofValidationError('
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!proof)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
!
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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_purpose_mismatch: '请求证明用途不匹配',
|
|
39
|
+
proof_session_mismatch: '请求证明与当前认证会话不匹配',
|
|
40
|
+
proof_target_mismatch: '一次性凭据的目标应用或密钥不匹配',
|
|
41
|
+
proof_token_mismatch: '请求 proof 绑定的 token 摘要不匹配',
|
|
42
|
+
proof_query_mismatch: '请求 proof 绑定的查询参数不匹配',
|
|
43
|
+
proof_body_mismatch: '请求 proof 绑定的请求内容不匹配',
|
|
44
|
+
proof_request_id_mismatch: '请求 proof 绑定的请求 ID 不匹配',
|
|
45
|
+
missing_proof_query_binding: '请求 proof 缺少查询参数绑定',
|
|
46
|
+
missing_proof_body_binding: '请求 proof 缺少请求内容绑定',
|
|
47
|
+
missing_proof_request_id_binding: '请求 proof 缺少请求 ID 绑定',
|
|
48
|
+
invalid_proof_signature: '请求 proof 签名验签失败',
|
|
49
|
+
proof_missing_or_invalid: '缺少 proof 或 proof 公钥无效',
|
|
50
|
+
missing_bound_proof: '当前浏览器会话未绑定请求 proof 公钥',
|
|
51
|
+
user_agent_changed: '请求 User-Agent 与登录绑定时不同',
|
|
52
|
+
accept_language_changed: '请求 Accept-Language 与登录绑定时不同',
|
|
53
|
+
verification_failed: '请求 proof 验证过程异常',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const getRequestProofReasonText = reason =>
|
|
57
|
+
REQUEST_PROOF_REASON_TEXT[String(reason || '')] || '未知请求 Proof 校验失败原因'
|
|
58
|
+
|
|
59
|
+
const safeEqual = (left, right) => {
|
|
60
|
+
const leftBuffer = Buffer.from(String(left ?? ''))
|
|
61
|
+
const rightBuffer = Buffer.from(String(right ?? ''))
|
|
62
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hashBindingValue = value => createHash('sha256').update(value).digest('base64url')
|
|
66
|
+
|
|
67
|
+
const deriveProofSessionId = sessionId => {
|
|
68
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
|
69
|
+
throw new RequestProofValidationError('missing_session_context')
|
|
70
|
+
}
|
|
71
|
+
return hashBindingValue(`ctx-session-v2:${sessionId}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hasTransferredRequestBody = options => {
|
|
75
|
+
const rawContentLength = Array.isArray(options?.contentLength)
|
|
76
|
+
? options.contentLength[0]
|
|
77
|
+
: options?.contentLength
|
|
78
|
+
const contentLengthText = String(rawContentLength ?? '').trim()
|
|
79
|
+
if (contentLengthText) {
|
|
80
|
+
const contentLength = Number(contentLengthText)
|
|
81
|
+
if (Number.isFinite(contentLength) && contentLength >= 0) return contentLength > 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const rawTransferEncoding = Array.isArray(options?.transferEncoding)
|
|
85
|
+
? options.transferEncoding[0]
|
|
86
|
+
: options?.transferEncoding
|
|
87
|
+
if (String(rawTransferEncoding ?? '').trim()) return true
|
|
88
|
+
|
|
89
|
+
const body = options?.body
|
|
90
|
+
if (body === undefined || body === null || body === '') return false
|
|
91
|
+
if (
|
|
92
|
+
typeof body === 'object' &&
|
|
93
|
+
!Array.isArray(body) &&
|
|
94
|
+
!Buffer.isBuffer(body) &&
|
|
95
|
+
Object.keys(body).length === 0
|
|
96
|
+
) {
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const calculateRequestProofBindings = options => ({
|
|
103
|
+
qsh: hashBindingValue(canonicalizeQuery(options?.path)),
|
|
104
|
+
bth: hashBindingValue(canonicalizeBody(options?.body, options?.contentType, options?.hasBody)),
|
|
105
|
+
rid: String(options?.requestId || '').trim(),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const normalizePublicKey = value => {
|
|
109
|
+
const x = String(value?.x || '').trim()
|
|
110
|
+
const y = String(value?.y || '').trim()
|
|
111
|
+
const isCoordinate = coordinate =>
|
|
112
|
+
coordinate.length >= 40 && coordinate.length <= 60 && /^[A-Za-z0-9_-]+$/.test(coordinate)
|
|
113
|
+
|
|
114
|
+
if (value?.kty !== 'EC' || value?.crv !== 'P-256' || !isCoordinate(x) || !isCoordinate(y)) {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
return { kty: 'EC', crv: 'P-256', x, y }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const normalizeRequestProofKey = async value => {
|
|
121
|
+
const publicKey = normalizePublicKey(value?.publicKey)
|
|
122
|
+
if (!publicKey) return null
|
|
123
|
+
|
|
124
|
+
const keyId = await calculateJwkThumbprint(publicKey, 'sha256')
|
|
125
|
+
const suppliedKeyId = String(value?.keyId || '').trim()
|
|
126
|
+
if (suppliedKeyId && !safeEqual(suppliedKeyId, keyId)) return null
|
|
127
|
+
return { keyId, publicKey }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const hashToken = token => createHash('sha256').update(token).digest('base64url')
|
|
131
|
+
|
|
132
|
+
const getRequestProofKeyId = proofValue => {
|
|
133
|
+
const proof = String(proofValue || '').trim()
|
|
134
|
+
if (!proof) throw new RequestProofValidationError('missing_proof')
|
|
135
|
+
if (proof.length < 64 || proof.length > 4096) {
|
|
136
|
+
throw new RequestProofValidationError('invalid_proof_format')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let protectedHeader
|
|
140
|
+
try {
|
|
141
|
+
protectedHeader = decodeProtectedHeader(proof)
|
|
142
|
+
} catch {
|
|
143
|
+
throw new RequestProofValidationError('invalid_proof_header')
|
|
144
|
+
}
|
|
145
|
+
if (!['dpop+jwt', 'ctx-proof+jwt'].includes(protectedHeader.typ) || protectedHeader.alg !== 'ES256') {
|
|
146
|
+
throw new RequestProofValidationError('invalid_proof_header')
|
|
147
|
+
}
|
|
148
|
+
const keyId = String(protectedHeader.kid || '').trim()
|
|
149
|
+
if (!keyId) throw new RequestProofValidationError('invalid_proof_header')
|
|
150
|
+
return keyId
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const verifyRequestProof = async options => {
|
|
154
|
+
const proof = String(options?.proof || '').trim()
|
|
155
|
+
if (!proof) throw new RequestProofValidationError('missing_proof')
|
|
156
|
+
if (proof.length < 64 || proof.length > 4096) {
|
|
157
|
+
throw new RequestProofValidationError('invalid_proof_format')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const keyId = getRequestProofKeyId(proof)
|
|
161
|
+
if (!safeEqual(keyId, options.keyId)) {
|
|
162
|
+
throw new RequestProofValidationError('proof_key_mismatch')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const verificationKey = await importJWK(options.publicKey, 'ES256')
|
|
167
|
+
const { payload } = await jwtVerify(proof, verificationKey, {
|
|
168
|
+
algorithms: ['ES256'],
|
|
169
|
+
audience: options.audience,
|
|
170
|
+
typ: options.purpose ? 'ctx-proof+jwt' : 'dpop+jwt',
|
|
171
|
+
})
|
|
172
|
+
const now = Math.floor(Date.now() / 1000)
|
|
173
|
+
const timeToleranceSeconds =
|
|
174
|
+
options.timeToleranceSeconds || DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS
|
|
175
|
+
const issuedAt = Number(payload.iat)
|
|
176
|
+
const timeWarning =
|
|
177
|
+
!Number.isInteger(issuedAt) || Math.abs(issuedAt - now) > timeToleranceSeconds
|
|
178
|
+
? {
|
|
179
|
+
reason: 'proof_expired',
|
|
180
|
+
issuedAt: Number.isInteger(issuedAt) ? issuedAt : null,
|
|
181
|
+
serverNow: now,
|
|
182
|
+
diffSeconds: Number.isInteger(issuedAt) ? issuedAt - now : null,
|
|
183
|
+
toleranceSeconds: timeToleranceSeconds,
|
|
184
|
+
}
|
|
185
|
+
: null
|
|
186
|
+
|
|
187
|
+
const jti = String(payload.jti || '').trim()
|
|
188
|
+
if (jti.length < 16 || jti.length > 200) {
|
|
189
|
+
throw new RequestProofValidationError('invalid_proof_jti')
|
|
190
|
+
}
|
|
191
|
+
if (String(payload.htm || '').toUpperCase() !== String(options.method || '').toUpperCase()) {
|
|
192
|
+
throw new RequestProofValidationError('proof_method_mismatch')
|
|
193
|
+
}
|
|
194
|
+
if (normalizePath(payload.htu) !== normalizePath(options.path)) {
|
|
195
|
+
throw new RequestProofValidationError('proof_path_mismatch')
|
|
196
|
+
}
|
|
197
|
+
if (options.purpose) {
|
|
198
|
+
if (payload.ver !== 2 || payload.purpose !== options.purpose) {
|
|
199
|
+
throw new RequestProofValidationError('proof_purpose_mismatch')
|
|
200
|
+
}
|
|
201
|
+
if (options.purpose === 'session') {
|
|
202
|
+
if (!safeEqual(payload.psid, deriveProofSessionId(options.sessionId))) {
|
|
203
|
+
throw new RequestProofValidationError('proof_session_mismatch')
|
|
204
|
+
}
|
|
205
|
+
} else if (options.purpose === 'ticket') {
|
|
206
|
+
if (!options.token || !safeEqual(payload.sth, hashToken(options.token))) {
|
|
207
|
+
throw new RequestProofValidationError('proof_token_mismatch')
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
throw new RequestProofValidationError('proof_purpose_mismatch')
|
|
211
|
+
}
|
|
212
|
+
return { jti, timeWarning }
|
|
213
|
+
}
|
|
214
|
+
if (!safeEqual(String(payload.ath || ''), hashToken(options.token))) {
|
|
215
|
+
throw new RequestProofValidationError('proof_token_mismatch')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const expectedBindings = calculateRequestProofBindings(options)
|
|
219
|
+
const queryHash = String(payload.qsh || '').trim()
|
|
220
|
+
if (options.requireRequestBindings && !queryHash) {
|
|
221
|
+
throw new RequestProofValidationError('missing_proof_query_binding')
|
|
222
|
+
}
|
|
223
|
+
if (queryHash && !safeEqual(queryHash, expectedBindings.qsh)) {
|
|
224
|
+
throw new RequestProofValidationError('proof_query_mismatch')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const bodyHash = String(payload.bth || '').trim()
|
|
228
|
+
if (options.requireRequestBindings && !bodyHash) {
|
|
229
|
+
throw new RequestProofValidationError('missing_proof_body_binding')
|
|
230
|
+
}
|
|
231
|
+
if (bodyHash && !safeEqual(bodyHash, expectedBindings.bth)) {
|
|
232
|
+
throw new RequestProofValidationError('proof_body_mismatch')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const requestId = String(payload.rid || '').trim()
|
|
236
|
+
if (options.requireRequestBindings && !requestId) {
|
|
237
|
+
throw new RequestProofValidationError('missing_proof_request_id_binding')
|
|
238
|
+
}
|
|
239
|
+
if (requestId && !safeEqual(requestId, expectedBindings.rid)) {
|
|
240
|
+
throw new RequestProofValidationError('proof_request_id_mismatch')
|
|
241
|
+
}
|
|
242
|
+
return { jti, timeWarning }
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (error instanceof RequestProofValidationError) throw error
|
|
245
|
+
throw new RequestProofValidationError('invalid_proof_signature')
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const readOverride = (env, name, fallback) => {
|
|
250
|
+
const value = env?.[name]
|
|
251
|
+
return value !== undefined && String(value).trim() !== '' ? value : fallback
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const integerSetting = (value, fallback, name, minimum, maximum) => {
|
|
255
|
+
if (value === undefined || String(value).trim() === '') return fallback
|
|
256
|
+
const parsed = Number(value)
|
|
257
|
+
if (
|
|
258
|
+
!Number.isInteger(parsed) ||
|
|
259
|
+
parsed < minimum ||
|
|
260
|
+
(maximum !== undefined && parsed > maximum)
|
|
261
|
+
) {
|
|
262
|
+
const range = maximum === undefined ? `at least ${minimum}` : `between ${minimum} and ${maximum}`
|
|
263
|
+
throw new Error(`${name} must be an integer ${range}`)
|
|
264
|
+
}
|
|
265
|
+
return parsed
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const resolveProofServerConfig = options => {
|
|
269
|
+
const env = options?.env ?? (typeof process !== 'undefined' ? process.env : {})
|
|
270
|
+
const mode = normalizeProofMode(readOverride(env, 'AUTH_PROOF_MODE', options?.mode))
|
|
271
|
+
const deviceSessionSecret = String(
|
|
272
|
+
readOverride(env, 'AUTH_PROOF_DEVICE_SESSION_SECRET', options?.deviceSessionSecret) || '',
|
|
273
|
+
).trim()
|
|
274
|
+
const deviceSessionTtlSeconds = integerSetting(
|
|
275
|
+
readOverride(
|
|
276
|
+
env,
|
|
277
|
+
'AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS',
|
|
278
|
+
options?.deviceSessionTtlSeconds,
|
|
279
|
+
),
|
|
280
|
+
12 * 60 * 60,
|
|
281
|
+
'AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS',
|
|
282
|
+
1,
|
|
283
|
+
)
|
|
284
|
+
const timeToleranceSeconds = integerSetting(
|
|
285
|
+
readOverride(env, 'AUTH_PROOF_TIME_TOLERANCE_SECONDS', options?.timeToleranceSeconds),
|
|
286
|
+
DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
|
|
287
|
+
'AUTH_PROOF_TIME_TOLERANCE_SECONDS',
|
|
288
|
+
30,
|
|
289
|
+
DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
|
|
290
|
+
)
|
|
291
|
+
if (mode !== 'off' && deviceSessionSecret.length < 32) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
'AUTH_PROOF_DEVICE_SESSION_SECRET is required when AUTH_PROOF_MODE is shadow or enforce and must contain at least 32 characters',
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
mode,
|
|
298
|
+
deviceSessionSecret,
|
|
299
|
+
deviceSessionTtlSeconds,
|
|
300
|
+
timeToleranceSeconds,
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = {
|
|
305
|
+
deriveProofSessionId,
|
|
306
|
+
REQUEST_PROOF_REASON_TEXT,
|
|
307
|
+
RequestProofValidationError,
|
|
308
|
+
calculateRequestProofBindings,
|
|
309
|
+
getRequestProofKeyId,
|
|
310
|
+
getRequestProofReasonText,
|
|
311
|
+
hasTransferredRequestBody,
|
|
312
|
+
normalizeProofMode,
|
|
313
|
+
normalizeRequestProofKey,
|
|
314
|
+
resolveProofServerConfig,
|
|
315
|
+
verifyRequestProof,
|
|
316
|
+
}
|