@travelclw/proof-protocol 0.1.0 → 0.1.2

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 CHANGED
@@ -36,7 +36,6 @@ pnpm add jose@^5
36
36
  - `AUTH_PROOF_DEVICE_SESSION_SECRET`
37
37
  - `AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS`
38
38
  - `AUTH_PROOF_TIME_TOLERANCE_SECONDS`
39
- - `AUTH_PROOF_SHADOW_BLOCK_MISSING_PROOF=0|1`
40
39
 
41
40
  服务端应在启动阶段调用 `resolveProofServerConfig()`:
42
41
 
@@ -50,11 +49,15 @@ const proofConfig = resolveProofServerConfig()
50
49
 
51
50
  环境变量中的非空值优先于调用方代码配置。缺少 TTL、时间窗口或 shadow 开关时使用协议默认值;显式配置非法值会直接报错,不会静默回退。
52
51
 
52
+ `verifyRequestProof()` 只负责验签、请求绑定和时间计算,过期/未来时间通过 `timeWarning` 返回;是否在 `enforce` 模式拦截、如何在 `shadow` 模式审计,以及如何用 Redis `SET NX EX` 原子消费 `jti`,由服务端适配层负责。
53
+
53
54
  ## 安全边界
54
55
 
55
56
  - 不要把 `.env`、真实密钥、Token、Cookie、私钥或生产配置提交到源码或发布到 npm。
56
57
  - `AUTH_PROOF_DEVICE_SESSION_SECRET` 只用于服务端设备会话摘要,不得暴露给浏览器。
57
58
  - 浏览器 P-256 私钥由 WebCrypto 创建并以不可导出 `CryptoKey` 保存到 IndexedDB。
59
+ - 同一浏览器应用的多个标签页共享同一个 IndexedDB 记录;`reset()` 会通过 `BroadcastChannel` 通知其它标签页清空内存中的进行中操作,并在每次读取/签名前重新核对 IndexedDB 当前 `kid`。不支持 `BroadcastChannel` 时仍以 IndexedDB 核对为准。
60
+ - `proofKeyPromise` 只缓存进行中的密钥创建操作,不缓存已完成的密钥 Promise;标签页休眠或漏收广播时,下一次签名仍会从 IndexedDB 发现 Key 已轮换。
58
61
  - 包只提供协议能力;会话存储、Redis 原子操作、Guard 策略和业务路由由使用方负责。
59
62
 
60
63
  ## 发布内容
package/browser.js CHANGED
@@ -59,6 +59,8 @@ const createBrowserProofClient = options => {
59
59
  const recordId = String(options?.recordId || 'request-proof-key').trim()
60
60
  const required = () => isRequestProofRequired(options?.required?.())
61
61
  let proofKeyPromise = null
62
+ let resetPromise = null
63
+ let resetChannel = null
62
64
 
63
65
  if (!databaseName || !storeName || !recordId) {
64
66
  throw new Error('request_proof_storage_failed')
@@ -148,42 +150,91 @@ const createBrowserProofClient = options => {
148
150
  }
149
151
  }
150
152
 
153
+ const getResetChannelName = () =>
154
+ `@travelclw/proof-protocol/reset/${databaseName}/${storeName}/${recordId}`
155
+
156
+ const invalidateLocalKey = () => {
157
+ proofKeyPromise = null
158
+ }
159
+
160
+ if (typeof globalThis.BroadcastChannel === 'function') {
161
+ try {
162
+ resetChannel = new globalThis.BroadcastChannel(getResetChannelName())
163
+ resetChannel.onmessage = event => {
164
+ if (event?.data?.type === 'request-proof-reset') invalidateLocalKey()
165
+ }
166
+ if (typeof resetChannel.unref === 'function') resetChannel.unref()
167
+ } catch {
168
+ resetChannel = null
169
+ }
170
+ }
171
+
172
+ const broadcastReset = () => {
173
+ try {
174
+ resetChannel?.postMessage({ type: 'request-proof-reset' })
175
+ } catch {
176
+ // IndexedDB verification below remains authoritative when the channel is unavailable.
177
+ }
178
+ }
179
+
151
180
  const getOrCreateKey = () => {
152
181
  if (!proofKeyPromise) {
153
- proofKeyPromise = (async () => {
182
+ const pendingReset = resetPromise
183
+ const operation = (async () => {
184
+ await pendingReset
154
185
  const storedKey = await readStoredKey()
155
186
  if (isStoredKeyValid(storedKey)) return storedKey
156
187
  return storeIfAbsent(await generateCandidate())
157
- })().catch(error => {
158
- proofKeyPromise = null
159
- throw error
188
+ })()
189
+ const pendingKey = operation.finally(() => {
190
+ if (proofKeyPromise === pendingKey) proofKeyPromise = null
160
191
  })
192
+ proofKeyPromise = pendingKey
161
193
  }
162
194
  return proofKeyPromise
163
195
  }
164
196
 
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()
197
+ const getCurrentKey = async () => {
198
+ while (true) {
199
+ const key = await getOrCreateKey()
200
+ const storedKey = await readStoredKey()
201
+ if (isStoredKeyValid(storedKey) && storedKey.keyId === key.keyId) return storedKey
202
+ invalidateLocalKey()
182
203
  }
183
204
  }
184
205
 
206
+ const reset = () => {
207
+ const pendingReset = resetPromise
208
+ const operation = (async () => {
209
+ broadcastReset()
210
+ const pendingKey = proofKeyPromise
211
+ invalidateLocalKey()
212
+ await pendingReset?.catch(() => undefined)
213
+ await pendingKey?.catch(() => undefined)
214
+ if (!globalThis.indexedDB) return
215
+
216
+ const database = await openDatabase()
217
+ try {
218
+ await new Promise((resolve, reject) => {
219
+ const transaction = database.transaction(storeName, 'readwrite')
220
+ transaction.objectStore(storeName).delete(recordId)
221
+ transaction.oncomplete = () => resolve()
222
+ transaction.onerror = () => reject(new Error('request_proof_reset_failed'))
223
+ transaction.onabort = () => reject(new Error('request_proof_reset_failed'))
224
+ })
225
+ } finally {
226
+ database.close()
227
+ }
228
+ })()
229
+
230
+ resetPromise = operation
231
+ return operation.finally(() => {
232
+ if (resetPromise === operation) resetPromise = null
233
+ })
234
+ }
235
+
185
236
  const getRegistration = async () => {
186
- const key = await getOrCreateKey()
237
+ const key = await getCurrentKey()
187
238
  return { keyId: key.keyId, publicKey: key.publicKey }
188
239
  }
189
240
 
@@ -205,13 +256,13 @@ const createBrowserProofClient = options => {
205
256
  const createProof = async input => {
206
257
  const token = String(input?.token || '').trim()
207
258
  if (!token) return ''
208
- const key = await getOrCreateKey()
209
259
  const requestId = String(input?.requestId || '').trim()
210
260
  const [ath, qsh, bth] = await Promise.all([
211
261
  sha256Base64Url(token),
212
262
  sha256Base64Url(canonicalizeQuery(input?.requestUri)),
213
263
  sha256Base64Url(canonicalizeBody(input?.body, input?.contentType, input?.hasBody)),
214
264
  ])
265
+ const key = await getCurrentKey()
215
266
  const header = { alg: 'ES256', kid: key.keyId, typ: 'dpop+jwt' }
216
267
  const payload = {
217
268
  ath,
package/node.d.ts CHANGED
@@ -41,7 +41,6 @@ export type ProofServerConfig = {
41
41
  deviceSessionSecret: string
42
42
  deviceSessionTtlSeconds: number
43
43
  timeToleranceSeconds: number
44
- shadowBlockMissingProof: boolean
45
44
  }
46
45
 
47
46
  export const REQUEST_PROOF_REASON_TEXT: Readonly<Record<string, string>>
@@ -72,7 +71,6 @@ export function resolveProofServerConfig(options?: {
72
71
  deviceSessionSecret?: string
73
72
  deviceSessionTtlSeconds?: number
74
73
  timeToleranceSeconds?: number
75
- shadowBlockMissingProof?: boolean
76
74
  }): ProofServerConfig
77
75
 
78
76
  export { RequestProofValidationError } from './core'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travelclw/proof-protocol",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Framework-independent browser and Node.js request Proof protocol",
5
5
  "license": "MIT",
6
6
  "author": "travelclw",
package/server.js CHANGED
@@ -54,8 +54,8 @@ const getRequestProofReasonText = reason =>
54
54
  REQUEST_PROOF_REASON_TEXT[String(reason || '')] || '未知请求 Proof 校验失败原因'
55
55
 
56
56
  const safeEqual = (left, right) => {
57
- const leftBuffer = Buffer.from(left)
58
- const rightBuffer = Buffer.from(right)
57
+ const leftBuffer = Buffer.from(String(left ?? ''))
58
+ const rightBuffer = Buffer.from(String(right ?? ''))
59
59
  return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
60
60
  }
61
61
 
@@ -238,15 +238,6 @@ const integerSetting = (value, fallback, name, minimum, maximum) => {
238
238
  return parsed
239
239
  }
240
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
241
  const resolveProofServerConfig = options => {
251
242
  const env = options?.env ?? (typeof process !== 'undefined' ? process.env : {})
252
243
  const mode = normalizeProofMode(readOverride(env, 'AUTH_PROOF_MODE', options?.mode))
@@ -270,16 +261,6 @@ const resolveProofServerConfig = options => {
270
261
  30,
271
262
  DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
272
263
  )
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
264
  if (mode !== 'off' && deviceSessionSecret.length < 32) {
284
265
  throw new Error(
285
266
  'AUTH_PROOF_DEVICE_SESSION_SECRET is required when AUTH_PROOF_MODE is shadow or enforce and must contain at least 32 characters',
@@ -290,7 +271,6 @@ const resolveProofServerConfig = options => {
290
271
  deviceSessionSecret,
291
272
  deviceSessionTtlSeconds,
292
273
  timeToleranceSeconds,
293
- shadowBlockMissingProof,
294
274
  }
295
275
  }
296
276