@travelclw/proof-protocol 0.1.1 → 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.
Files changed (7) hide show
  1. package/README.md +81 -65
  2. package/browser.d.ts +42 -40
  3. package/browser.js +342 -267
  4. package/core.js +221 -219
  5. package/node.d.ts +80 -76
  6. package/package.json +1 -10
  7. package/server.js +316 -288
package/browser.js CHANGED
@@ -1,267 +1,342 @@
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
- let resetPromise = null
63
-
64
- if (!databaseName || !storeName || !recordId) {
65
- throw new Error('request_proof_storage_failed')
66
- }
67
-
68
- const openDatabase = () =>
69
- new Promise((resolve, reject) => {
70
- if (!globalThis.indexedDB) {
71
- reject(new Error('request_proof_storage_unsupported'))
72
- return
73
- }
74
- const request = globalThis.indexedDB.open(databaseName, 1)
75
- request.onupgradeneeded = () => {
76
- const database = request.result
77
- if (!database.objectStoreNames.contains(storeName)) {
78
- database.createObjectStore(storeName, { keyPath: 'id' })
79
- }
80
- }
81
- request.onsuccess = () => resolve(request.result)
82
- request.onerror = () => reject(new Error('request_proof_storage_failed'))
83
- })
84
-
85
- const readStoredKey = async () => {
86
- const database = await openDatabase()
87
- try {
88
- return await new Promise((resolve, reject) => {
89
- const request = database.transaction(storeName, 'readonly').objectStore(storeName).get(recordId)
90
- request.onsuccess = () => resolve(request.result || null)
91
- request.onerror = () => reject(new Error('request_proof_read_failed'))
92
- })
93
- } finally {
94
- database.close()
95
- }
96
- }
97
-
98
- const isStoredKeyValid = record => {
99
- if (!record?.keyId || !record.publicKey?.x || !record.publicKey?.y || !record.privateKey) return false
100
- const algorithm = record.privateKey.algorithm
101
- return (
102
- record.privateKey.type === 'private' &&
103
- record.privateKey.extractable === false &&
104
- algorithm?.name === 'ECDSA' &&
105
- algorithm?.namedCurve === 'P-256' &&
106
- Array.isArray(record.privateKey.usages) &&
107
- record.privateKey.usages.includes('sign')
108
- )
109
- }
110
-
111
- const generateCandidate = async () => {
112
- const webCrypto = getWebCrypto()
113
- const keyPair = await webCrypto.subtle.generateKey(
114
- { name: 'ECDSA', namedCurve: 'P-256' },
115
- false,
116
- ['sign', 'verify'],
117
- )
118
- const publicKey = normalizePublicKey(await webCrypto.subtle.exportKey('jwk', keyPair.publicKey))
119
- return {
120
- id: recordId,
121
- keyId: await calculateKeyId(publicKey),
122
- publicKey,
123
- privateKey: keyPair.privateKey,
124
- createdAt: new Date().toISOString(),
125
- }
126
- }
127
-
128
- const storeIfAbsent = async candidate => {
129
- const database = await openDatabase()
130
- let selectedKey = candidate
131
- try {
132
- await new Promise((resolve, reject) => {
133
- const transaction = database.transaction(storeName, 'readwrite')
134
- const store = transaction.objectStore(storeName)
135
- const readRequest = store.get(recordId)
136
- readRequest.onsuccess = () => {
137
- const storedKey = readRequest.result || null
138
- if (isStoredKeyValid(storedKey)) selectedKey = storedKey
139
- else store.put(candidate)
140
- }
141
- readRequest.onerror = () => reject(new Error('request_proof_read_failed'))
142
- transaction.oncomplete = () => resolve()
143
- transaction.onerror = () => reject(new Error('request_proof_write_failed'))
144
- transaction.onabort = () => reject(new Error('request_proof_write_failed'))
145
- })
146
- return selectedKey
147
- } finally {
148
- database.close()
149
- }
150
- }
151
-
152
- const getOrCreateKey = () => {
153
- if (!proofKeyPromise) {
154
- const pendingReset = resetPromise
155
- proofKeyPromise = (async () => {
156
- await pendingReset
157
- const storedKey = await readStoredKey()
158
- if (isStoredKeyValid(storedKey)) return storedKey
159
- return storeIfAbsent(await generateCandidate())
160
- })().catch(error => {
161
- proofKeyPromise = null
162
- throw error
163
- })
164
- }
165
- return proofKeyPromise
166
- }
167
-
168
- const reset = () => {
169
- const pendingReset = resetPromise
170
- const operation = (async () => {
171
- const pendingKey = proofKeyPromise
172
- proofKeyPromise = null
173
- await pendingReset?.catch(() => undefined)
174
- await pendingKey?.catch(() => undefined)
175
- if (!globalThis.indexedDB) return
176
-
177
- const database = await openDatabase()
178
- try {
179
- await new Promise((resolve, reject) => {
180
- const transaction = database.transaction(storeName, 'readwrite')
181
- transaction.objectStore(storeName).delete(recordId)
182
- transaction.oncomplete = () => resolve()
183
- transaction.onerror = () => reject(new Error('request_proof_reset_failed'))
184
- transaction.onabort = () => reject(new Error('request_proof_reset_failed'))
185
- })
186
- } finally {
187
- database.close()
188
- }
189
- })()
190
-
191
- resetPromise = operation
192
- return operation.finally(() => {
193
- if (resetPromise === operation) resetPromise = null
194
- })
195
- }
196
-
197
- const getRegistration = async () => {
198
- const key = await getOrCreateKey()
199
- return { keyId: key.keyId, publicKey: key.publicKey }
200
- }
201
-
202
- const runtimeUnavailable = () =>
203
- (typeof window !== 'undefined' && window.isSecureContext === false) ||
204
- !globalThis.crypto?.subtle ||
205
- !globalThis.indexedDB
206
-
207
- const tryGetRegistration = async () => {
208
- if (!required() && runtimeUnavailable()) return null
209
- try {
210
- return await getRegistration()
211
- } catch (error) {
212
- if (!required() && isRequestProofSetupError(error)) return null
213
- throw error
214
- }
215
- }
216
-
217
- const createProof = async input => {
218
- const token = String(input?.token || '').trim()
219
- if (!token) return ''
220
- const key = await getOrCreateKey()
221
- const requestId = String(input?.requestId || '').trim()
222
- const [ath, qsh, bth] = await Promise.all([
223
- sha256Base64Url(token),
224
- sha256Base64Url(canonicalizeQuery(input?.requestUri)),
225
- sha256Base64Url(canonicalizeBody(input?.body, input?.contentType, input?.hasBody)),
226
- ])
227
- const header = { alg: 'ES256', kid: key.keyId, typ: 'dpop+jwt' }
228
- const payload = {
229
- ath,
230
- aud: String(input?.audience || ''),
231
- htm: String(input?.method || 'GET').toUpperCase(),
232
- htu: normalizePath(input?.path),
233
- qsh,
234
- bth,
235
- rid: requestId,
236
- iat: Math.floor(Date.now() / 1000),
237
- jti: createProofRequestId(),
238
- }
239
- const signingInput = `${textToBase64Url(JSON.stringify(header))}.${textToBase64Url(JSON.stringify(payload))}`
240
- let signature
241
- try {
242
- signature = await getWebCrypto().subtle.sign(
243
- { name: 'ECDSA', hash: 'SHA-256' },
244
- key.privateKey,
245
- new TextEncoder().encode(signingInput),
246
- )
247
- } catch {
248
- throw new Error('request_proof_sign_failed')
249
- }
250
- return `${signingInput}.${bytesToBase64Url(new Uint8Array(signature))}`
251
- }
252
-
253
- const tryCreateProof = async input => {
254
- if (!String(input?.token || '').trim()) return ''
255
- if (!required() && runtimeUnavailable()) return ''
256
- try {
257
- return await createProof(input)
258
- } catch (error) {
259
- if (!required() && isRequestProofSetupError(error)) return ''
260
- throw error
261
- }
262
- }
263
-
264
- return { createProof, getRegistration, reset, tryCreateProof, tryGetRegistration }
265
- }
266
-
267
- module.exports = { calculateKeyId, createBrowserProofClient, createProofRequestId }
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
+ let resetPromise = null
63
+ let resetChannel = null
64
+
65
+ if (!databaseName || !storeName || !recordId) {
66
+ throw new Error('request_proof_storage_failed')
67
+ }
68
+
69
+ const openDatabase = () =>
70
+ new Promise((resolve, reject) => {
71
+ if (!globalThis.indexedDB) {
72
+ reject(new Error('request_proof_storage_unsupported'))
73
+ return
74
+ }
75
+ const request = globalThis.indexedDB.open(databaseName, 1)
76
+ request.onupgradeneeded = () => {
77
+ const database = request.result
78
+ if (!database.objectStoreNames.contains(storeName)) {
79
+ database.createObjectStore(storeName, { keyPath: 'id' })
80
+ }
81
+ }
82
+ request.onsuccess = () => resolve(request.result)
83
+ request.onerror = () => reject(new Error('request_proof_storage_failed'))
84
+ })
85
+
86
+ const readStoredKey = async () => {
87
+ const database = await openDatabase()
88
+ try {
89
+ return await new Promise((resolve, reject) => {
90
+ const request = database.transaction(storeName, 'readonly').objectStore(storeName).get(recordId)
91
+ request.onsuccess = () => resolve(request.result || null)
92
+ request.onerror = () => reject(new Error('request_proof_read_failed'))
93
+ })
94
+ } finally {
95
+ database.close()
96
+ }
97
+ }
98
+
99
+ const isStoredKeyValid = record => {
100
+ if (!record?.keyId || !record.publicKey?.x || !record.publicKey?.y || !record.privateKey) return false
101
+ const algorithm = record.privateKey.algorithm
102
+ return (
103
+ record.privateKey.type === 'private' &&
104
+ record.privateKey.extractable === false &&
105
+ algorithm?.name === 'ECDSA' &&
106
+ algorithm?.namedCurve === 'P-256' &&
107
+ Array.isArray(record.privateKey.usages) &&
108
+ record.privateKey.usages.includes('sign')
109
+ )
110
+ }
111
+
112
+ const generateCandidate = async () => {
113
+ const webCrypto = getWebCrypto()
114
+ const keyPair = await webCrypto.subtle.generateKey(
115
+ { name: 'ECDSA', namedCurve: 'P-256' },
116
+ false,
117
+ ['sign', 'verify'],
118
+ )
119
+ const publicKey = normalizePublicKey(await webCrypto.subtle.exportKey('jwk', keyPair.publicKey))
120
+ return {
121
+ id: recordId,
122
+ keyId: await calculateKeyId(publicKey),
123
+ publicKey,
124
+ privateKey: keyPair.privateKey,
125
+ createdAt: new Date().toISOString(),
126
+ }
127
+ }
128
+
129
+ const storeIfAbsent = async candidate => {
130
+ const database = await openDatabase()
131
+ let selectedKey = candidate
132
+ try {
133
+ await new Promise((resolve, reject) => {
134
+ const transaction = database.transaction(storeName, 'readwrite')
135
+ const store = transaction.objectStore(storeName)
136
+ const readRequest = store.get(recordId)
137
+ readRequest.onsuccess = () => {
138
+ const storedKey = readRequest.result || null
139
+ if (isStoredKeyValid(storedKey)) selectedKey = storedKey
140
+ else store.put(candidate)
141
+ }
142
+ readRequest.onerror = () => reject(new Error('request_proof_read_failed'))
143
+ transaction.oncomplete = () => resolve()
144
+ transaction.onerror = () => reject(new Error('request_proof_write_failed'))
145
+ transaction.onabort = () => reject(new Error('request_proof_write_failed'))
146
+ })
147
+ return selectedKey
148
+ } finally {
149
+ database.close()
150
+ }
151
+ }
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
+
180
+ const getOrCreateKey = () => {
181
+ if (!proofKeyPromise) {
182
+ const pendingReset = resetPromise
183
+ const operation = (async () => {
184
+ await pendingReset
185
+ const storedKey = await readStoredKey()
186
+ if (isStoredKeyValid(storedKey)) return storedKey
187
+ return storeIfAbsent(await generateCandidate())
188
+ })()
189
+ const pendingKey = operation.finally(() => {
190
+ if (proofKeyPromise === pendingKey) proofKeyPromise = null
191
+ })
192
+ proofKeyPromise = pendingKey
193
+ }
194
+ return proofKeyPromise
195
+ }
196
+
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()
203
+ }
204
+ }
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
+
236
+ const getRegistration = async () => {
237
+ const key = await getCurrentKey()
238
+ return { keyId: key.keyId, publicKey: key.publicKey }
239
+ }
240
+
241
+ const runtimeUnavailable = () =>
242
+ (typeof window !== 'undefined' && window.isSecureContext === false) ||
243
+ !globalThis.crypto?.subtle ||
244
+ !globalThis.indexedDB
245
+
246
+ const tryGetRegistration = async () => {
247
+ if (!required() && runtimeUnavailable()) return null
248
+ try {
249
+ return await getRegistration()
250
+ } catch (error) {
251
+ if (!required() && isRequestProofSetupError(error)) return null
252
+ throw error
253
+ }
254
+ }
255
+
256
+ const createContextProof = async input => {
257
+ if (!['session', 'ticket'].includes(input.purpose)) throw new Error('request_proof_context_invalid')
258
+ const key = await readStoredKey()
259
+ if (!isStoredKeyValid(key) || resetPromise) throw new Error('request_proof_key_missing')
260
+ if (input.purpose === 'ticket' && key.keyId !== input.expectedKeyId) {
261
+ throw new Error('request_proof_key_changed')
262
+ }
263
+ let binding
264
+ if (input.purpose === 'session') {
265
+ try {
266
+ const header = JSON.parse(atob(String(input.token).split('.')[0].replace(/-/g, '+').replace(/_/g, '/')))
267
+ if (typeof header.psid !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(header.psid)) throw new Error('missing')
268
+ binding = { psid: header.psid }
269
+ } catch { throw new Error('request_proof_session_missing') }
270
+ } else {
271
+ if (!input.token) throw new Error('request_proof_context_invalid')
272
+ binding = { sth: await sha256Base64Url(input.token) }
273
+ }
274
+ const header = { alg: 'ES256', kid: key.keyId, typ: 'ctx-proof+jwt' }
275
+ const payload = {
276
+ ver: 2, purpose: input.purpose, ...binding,
277
+ aud: input.audience, htm: String(input.method).toUpperCase(), htu: normalizePath(input.path),
278
+ iat: Math.floor(Date.now() / 1000), jti: createProofRequestId(),
279
+ }
280
+ const signingInput = `${textToBase64Url(JSON.stringify(header))}.${textToBase64Url(JSON.stringify(payload))}`
281
+ const signature = await getWebCrypto().subtle.sign(
282
+ { name: 'ECDSA', hash: 'SHA-256' }, key.privateKey, new TextEncoder().encode(signingInput),
283
+ )
284
+ const current = await readStoredKey()
285
+ if (resetPromise || !isStoredKeyValid(current) || current.keyId !== key.keyId) {
286
+ throw new Error('request_proof_key_changed')
287
+ }
288
+ return `${signingInput}.${bytesToBase64Url(new Uint8Array(signature))}`
289
+ }
290
+
291
+ const createProof = async input => {
292
+ if (input?.purpose) return createContextProof(input)
293
+ const token = String(input?.token || '').trim()
294
+ if (!token) return ''
295
+ const requestId = String(input?.requestId || '').trim()
296
+ const [ath, qsh, bth] = await Promise.all([
297
+ sha256Base64Url(token),
298
+ sha256Base64Url(canonicalizeQuery(input?.requestUri)),
299
+ sha256Base64Url(canonicalizeBody(input?.body, input?.contentType, input?.hasBody)),
300
+ ])
301
+ const key = await getCurrentKey()
302
+ const header = { alg: 'ES256', kid: key.keyId, typ: 'dpop+jwt' }
303
+ const payload = {
304
+ ath,
305
+ aud: String(input?.audience || ''),
306
+ htm: String(input?.method || 'GET').toUpperCase(),
307
+ htu: normalizePath(input?.path),
308
+ qsh,
309
+ bth,
310
+ rid: requestId,
311
+ iat: Math.floor(Date.now() / 1000),
312
+ jti: createProofRequestId(),
313
+ }
314
+ const signingInput = `${textToBase64Url(JSON.stringify(header))}.${textToBase64Url(JSON.stringify(payload))}`
315
+ let signature
316
+ try {
317
+ signature = await getWebCrypto().subtle.sign(
318
+ { name: 'ECDSA', hash: 'SHA-256' },
319
+ key.privateKey,
320
+ new TextEncoder().encode(signingInput),
321
+ )
322
+ } catch {
323
+ throw new Error('request_proof_sign_failed')
324
+ }
325
+ return `${signingInput}.${bytesToBase64Url(new Uint8Array(signature))}`
326
+ }
327
+
328
+ const tryCreateProof = async input => {
329
+ if (!String(input?.token || '').trim()) return ''
330
+ if (!input?.purpose && !required() && runtimeUnavailable()) return ''
331
+ try {
332
+ return await createProof(input)
333
+ } catch (error) {
334
+ if (!input?.purpose && !required() && isRequestProofSetupError(error)) return ''
335
+ throw error
336
+ }
337
+ }
338
+
339
+ return { createProof, getRegistration, reset, tryCreateProof, tryGetRegistration }
340
+ }
341
+
342
+ module.exports = { calculateKeyId, createBrowserProofClient, createProofRequestId }