@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 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
+ }