@bsv/sdk 1.3.28 → 1.3.30
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/dist/cjs/package.json +1 -1
- package/dist/cjs/src/auth/Peer.js +142 -91
- package/dist/cjs/src/auth/Peer.js.map +1 -1
- package/dist/cjs/src/auth/SessionManager.js +82 -21
- package/dist/cjs/src/auth/SessionManager.js.map +1 -1
- package/dist/cjs/src/auth/clients/AuthFetch.js +50 -41
- package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/auth/Peer.js +138 -88
- package/dist/esm/src/auth/Peer.js.map +1 -1
- package/dist/esm/src/auth/SessionManager.js +88 -22
- package/dist/esm/src/auth/SessionManager.js.map +1 -1
- package/dist/esm/src/auth/clients/AuthFetch.js +49 -41
- package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/auth/Peer.d.ts +21 -23
- package/dist/types/src/auth/Peer.d.ts.map +1 -1
- package/dist/types/src/auth/SessionManager.d.ts +25 -7
- package/dist/types/src/auth/SessionManager.d.ts.map +1 -1
- package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
- package/dist/types/src/auth/types.d.ts +1 -0
- package/dist/types/src/auth/types.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +1 -1
- package/docs/auth.md +33 -38
- package/package.json +1 -1
- package/src/auth/Peer.ts +186 -130
- package/src/auth/SessionManager.ts +89 -22
- package/src/auth/__tests/Peer.test.ts +66 -0
- package/src/auth/__tests/SessionManager.test.ts +3 -2
- package/src/auth/clients/AuthFetch.ts +53 -43
- package/src/auth/types.ts +1 -0
|
@@ -1,55 +1,112 @@
|
|
|
1
1
|
import { PeerSession } from './types.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Manages sessions for peers, allowing
|
|
5
|
-
*
|
|
4
|
+
* Manages sessions for peers, allowing multiple concurrent sessions
|
|
5
|
+
* per identity key. Primary lookup is always by `sessionNonce`.
|
|
6
6
|
*/
|
|
7
7
|
export class SessionManager {
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Maps sessionNonce -> PeerSession
|
|
10
|
+
*/
|
|
11
|
+
private readonly sessionNonceToSession: Map<string, PeerSession>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maps identityKey -> Set of sessionNonces
|
|
15
|
+
*/
|
|
16
|
+
private readonly identityKeyToNonces: Map<string, Set<string>>
|
|
9
17
|
|
|
10
18
|
constructor () {
|
|
11
|
-
this.
|
|
19
|
+
this.sessionNonceToSession = new Map<string, PeerSession>()
|
|
20
|
+
this.identityKeyToNonces = new Map<string, Set<string>>()
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
/**
|
|
15
|
-
* Adds a session to the manager, associating it with
|
|
24
|
+
* Adds a session to the manager, associating it with its sessionNonce,
|
|
25
|
+
* and also with its peerIdentityKey (if any).
|
|
26
|
+
*
|
|
27
|
+
* This does NOT overwrite existing sessions for the same peerIdentityKey,
|
|
28
|
+
* allowing multiple concurrent sessions for the same peer.
|
|
16
29
|
*
|
|
17
30
|
* @param {PeerSession} session - The peer session to add.
|
|
18
31
|
*/
|
|
19
32
|
addSession (session: PeerSession): void {
|
|
20
|
-
if (
|
|
21
|
-
(session.peerIdentityKey === null || session.peerIdentityKey === undefined || session.peerIdentityKey === '')) {
|
|
33
|
+
if (typeof session.sessionNonce !== 'string') {
|
|
22
34
|
throw new Error(
|
|
23
|
-
'Invalid session:
|
|
35
|
+
'Invalid session: sessionNonce is required to add a session.'
|
|
24
36
|
)
|
|
25
37
|
}
|
|
26
38
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
// Use the sessionNonce as the primary key
|
|
40
|
+
this.sessionNonceToSession.set(session.sessionNonce, session)
|
|
41
|
+
|
|
42
|
+
// Also track it by identity key if present
|
|
43
|
+
if (typeof session.peerIdentityKey === 'string') {
|
|
44
|
+
let nonces = this.identityKeyToNonces.get(session.peerIdentityKey)
|
|
45
|
+
if (nonces == null) {
|
|
46
|
+
nonces = new Set<string>()
|
|
47
|
+
this.identityKeyToNonces.set(session.peerIdentityKey, nonces)
|
|
48
|
+
}
|
|
49
|
+
nonces.add(session.sessionNonce)
|
|
32
50
|
}
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
/**
|
|
36
|
-
* Updates a session in the manager
|
|
54
|
+
* Updates a session in the manager (primarily by re-adding it),
|
|
55
|
+
* ensuring we record the latest data (e.g., isAuthenticated, lastUpdate, etc.).
|
|
37
56
|
*
|
|
38
57
|
* @param {PeerSession} session - The peer session to update.
|
|
39
58
|
*/
|
|
40
59
|
updateSession (session: PeerSession): void {
|
|
60
|
+
// Remove the old references (if any) and re-add
|
|
41
61
|
this.removeSession(session)
|
|
42
62
|
this.addSession(session)
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
/**
|
|
46
|
-
* Retrieves a session based on a given identifier
|
|
66
|
+
* Retrieves a session based on a given identifier, which can be:
|
|
67
|
+
* - A sessionNonce, or
|
|
68
|
+
* - A peerIdentityKey.
|
|
69
|
+
*
|
|
70
|
+
* If it is a `sessionNonce`, returns that exact session.
|
|
71
|
+
* If it is a `peerIdentityKey`, returns the "best" (e.g. most recently updated,
|
|
72
|
+
* authenticated) session associated with that peer, if any.
|
|
47
73
|
*
|
|
48
74
|
* @param {string} identifier - The identifier for the session (sessionNonce or peerIdentityKey).
|
|
49
75
|
* @returns {PeerSession | undefined} - The matching peer session, or undefined if not found.
|
|
50
76
|
*/
|
|
51
77
|
getSession (identifier: string): PeerSession | undefined {
|
|
52
|
-
|
|
78
|
+
// Check if this identifier is directly a sessionNonce
|
|
79
|
+
const direct = this.sessionNonceToSession.get(identifier)
|
|
80
|
+
if (direct != null) {
|
|
81
|
+
return direct
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Otherwise, interpret the identifier as an identity key
|
|
85
|
+
const nonces = this.identityKeyToNonces.get(identifier)
|
|
86
|
+
if ((nonces == null) || nonces.size === 0) {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Pick the "best" session. One sensible approach:
|
|
91
|
+
// - Choose an authenticated session if available
|
|
92
|
+
// - Among them, pick the most recently updated
|
|
93
|
+
let best: PeerSession | undefined
|
|
94
|
+
for (const nonce of nonces) {
|
|
95
|
+
const s = this.sessionNonceToSession.get(nonce)
|
|
96
|
+
if (s == null) continue
|
|
97
|
+
// We can prefer authenticated sessions
|
|
98
|
+
if (best == null) {
|
|
99
|
+
best = s
|
|
100
|
+
} else {
|
|
101
|
+
// If we want the "most recently updated" AND isAuthenticated
|
|
102
|
+
if ((s.lastUpdate ?? 0) > (best.lastUpdate ?? 0)) {
|
|
103
|
+
best = s
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Optionally, you could also filter out isAuthenticated===false if you only want
|
|
108
|
+
// an authenticated session. But for our usage, let's return the latest any session.
|
|
109
|
+
return best
|
|
53
110
|
}
|
|
54
111
|
|
|
55
112
|
/**
|
|
@@ -58,21 +115,31 @@ export class SessionManager {
|
|
|
58
115
|
* @param {PeerSession} session - The peer session to remove.
|
|
59
116
|
*/
|
|
60
117
|
removeSession (session: PeerSession): void {
|
|
61
|
-
if (
|
|
62
|
-
this.
|
|
118
|
+
if (typeof session.sessionNonce === 'string') {
|
|
119
|
+
this.sessionNonceToSession.delete(session.sessionNonce)
|
|
63
120
|
}
|
|
64
|
-
if (
|
|
65
|
-
this.
|
|
121
|
+
if (typeof session.peerIdentityKey === 'string') {
|
|
122
|
+
const nonces = this.identityKeyToNonces.get(session.peerIdentityKey)
|
|
123
|
+
if (nonces != null) {
|
|
124
|
+
nonces.delete(session.sessionNonce ?? '')
|
|
125
|
+
if (nonces.size === 0) {
|
|
126
|
+
this.identityKeyToNonces.delete(session.peerIdentityKey)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
66
129
|
}
|
|
67
130
|
}
|
|
68
131
|
|
|
69
132
|
/**
|
|
70
|
-
* Checks if a session exists
|
|
133
|
+
* Checks if a session exists for a given identifier (either sessionNonce or identityKey).
|
|
71
134
|
*
|
|
72
135
|
* @param {string} identifier - The identifier to check.
|
|
73
136
|
* @returns {boolean} - True if the session exists, false otherwise.
|
|
74
137
|
*/
|
|
75
138
|
hasSession (identifier: string): boolean {
|
|
76
|
-
|
|
139
|
+
const direct = this.sessionNonceToSession.has(identifier)
|
|
140
|
+
if (direct) return true
|
|
141
|
+
// if not directly a nonce, interpret as identityKey
|
|
142
|
+
const nonces = this.identityKeyToNonces.get(identifier)
|
|
143
|
+
return !(nonces == null) && nonces.size > 0
|
|
77
144
|
}
|
|
78
145
|
}
|
|
@@ -232,6 +232,72 @@ describe('Peer class mutual authentication and certificate exchange', () => {
|
|
|
232
232
|
expect(certificatesReceivedByBob).toEqual([])
|
|
233
233
|
}, 15000)
|
|
234
234
|
|
|
235
|
+
it('Alice talks to Bob across two devices, Bob can respond across both sessions', async () => {
|
|
236
|
+
const transportA1 = new LocalTransport()
|
|
237
|
+
const transportA2 = new LocalTransport()
|
|
238
|
+
const transportB = new LocalTransport()
|
|
239
|
+
transportA1.connect(transportB)
|
|
240
|
+
const aliceKey = PrivateKey.fromRandom()
|
|
241
|
+
const walletA1 = new CompletedProtoWallet(aliceKey)
|
|
242
|
+
const walletA2 = new CompletedProtoWallet(aliceKey)
|
|
243
|
+
const walletB = new CompletedProtoWallet(PrivateKey.fromRandom())
|
|
244
|
+
const aliceFirstDevice = new Peer(
|
|
245
|
+
walletA1,
|
|
246
|
+
transportA1
|
|
247
|
+
)
|
|
248
|
+
const aliceOtherDevice = new Peer(
|
|
249
|
+
walletA2,
|
|
250
|
+
transportA2
|
|
251
|
+
)
|
|
252
|
+
const bob = new Peer(
|
|
253
|
+
walletB,
|
|
254
|
+
transportB
|
|
255
|
+
)
|
|
256
|
+
const alice1MessageHandler = jest.fn()
|
|
257
|
+
const alice2MessageHandler = jest.fn()
|
|
258
|
+
const bobMessageHandler = jest.fn()
|
|
259
|
+
|
|
260
|
+
const bobReceivedGeneralMessage = new Promise<void>((resolve) => {
|
|
261
|
+
bob.listenForGeneralMessages((senderPublicKey, payload) => {
|
|
262
|
+
(async () => {
|
|
263
|
+
console.log('Bob 1 received message:', Utils.toUTF8(payload))
|
|
264
|
+
await bob.toPeer(Utils.toArray('Hello Alice!'), senderPublicKey)
|
|
265
|
+
resolve()
|
|
266
|
+
bobMessageHandler(senderPublicKey, payload)
|
|
267
|
+
})().catch(e => console.log(e))
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
let aliceReceivedGeneralMessageOnFirstDevice = new Promise<void>((resolve) => {
|
|
271
|
+
aliceFirstDevice.listenForGeneralMessages((senderPublicKey, payload) => {
|
|
272
|
+
(async () => {
|
|
273
|
+
console.log('Alice 1 received message:', Utils.toUTF8(payload))
|
|
274
|
+
resolve()
|
|
275
|
+
alice1MessageHandler(senderPublicKey, payload)
|
|
276
|
+
})().catch(e => console.log(e))
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
const aliceReceivedGeneralMessageOnOtherDevice = new Promise<void>((resolve) => {
|
|
280
|
+
aliceOtherDevice.listenForGeneralMessages((senderPublicKey, payload) => {
|
|
281
|
+
(async () => {
|
|
282
|
+
console.log('Alice 2 received message:', Utils.toUTF8(payload))
|
|
283
|
+
resolve()
|
|
284
|
+
alice2MessageHandler(senderPublicKey, payload)
|
|
285
|
+
})().catch(e => console.log(e))
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
await aliceFirstDevice.toPeer(Utils.toArray('Hello Bob!'))
|
|
290
|
+
await bobReceivedGeneralMessage
|
|
291
|
+
await aliceReceivedGeneralMessageOnFirstDevice
|
|
292
|
+
transportA2.connect(transportB)
|
|
293
|
+
await aliceOtherDevice.toPeer(Utils.toArray('Hello Bob from my other device!'))
|
|
294
|
+
await aliceReceivedGeneralMessageOnOtherDevice
|
|
295
|
+
transportA1.connect(transportB)
|
|
296
|
+
await aliceFirstDevice.toPeer(Utils.toArray('Back on my first device now, Bob! Can you still hear me?'))
|
|
297
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
298
|
+
expect(alice1MessageHandler.mock.calls.length).toEqual(2)
|
|
299
|
+
}, 30000)
|
|
300
|
+
|
|
235
301
|
it('Bob requests certificates from Alice, Alice does not request any from Bob', async () => {
|
|
236
302
|
const alicePubKey = (await walletA.getPublicKey({ identityKey: true }))
|
|
237
303
|
.publicKey
|
|
@@ -10,7 +10,8 @@ describe('SessionManager', () => {
|
|
|
10
10
|
validSession = {
|
|
11
11
|
isAuthenticated: false,
|
|
12
12
|
sessionNonce: 'testSessionNonce',
|
|
13
|
-
peerIdentityKey: 'testPeerIdentityKey'
|
|
13
|
+
peerIdentityKey: 'testPeerIdentityKey',
|
|
14
|
+
lastUpdate: 1
|
|
14
15
|
}
|
|
15
16
|
})
|
|
16
17
|
|
|
@@ -39,7 +40,7 @@ describe('SessionManager', () => {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
expect(() => sessionManager.addSession(invalidSession)).toThrow(
|
|
42
|
-
'Invalid session:
|
|
43
|
+
'Invalid session: sessionNonce is required to add a session.'
|
|
43
44
|
)
|
|
44
45
|
})
|
|
45
46
|
|
|
@@ -64,7 +64,7 @@ export class AuthFetch {
|
|
|
64
64
|
}
|
|
65
65
|
config.retryCounter--
|
|
66
66
|
}
|
|
67
|
-
const response = await new Promise<Response>(async (resolve, reject) => {
|
|
67
|
+
const response = await new Promise<Response>((async (resolve, reject) => {
|
|
68
68
|
try {
|
|
69
69
|
// Apply defaults
|
|
70
70
|
const { method = 'GET', headers = {}, body } = config
|
|
@@ -75,7 +75,7 @@ export class AuthFetch {
|
|
|
75
75
|
|
|
76
76
|
// Create a new transport for this base url if needed
|
|
77
77
|
let peerToUse: AuthPeer
|
|
78
|
-
if (
|
|
78
|
+
if (typeof this.peers[baseURL] === 'undefined') {
|
|
79
79
|
// Create a peer for the request
|
|
80
80
|
const newTransport = new SimplifiedFetchTransport(baseURL)
|
|
81
81
|
peerToUse = {
|
|
@@ -96,6 +96,7 @@ export class AuthFetch {
|
|
|
96
96
|
} catch (error) {
|
|
97
97
|
reject(error)
|
|
98
98
|
}
|
|
99
|
+
return
|
|
99
100
|
}
|
|
100
101
|
peerToUse = this.peers[baseURL]
|
|
101
102
|
}
|
|
@@ -119,59 +120,68 @@ export class AuthFetch {
|
|
|
119
120
|
const responseReader = new Utils.Reader(payload)
|
|
120
121
|
// Deserialize first 32 bytes of payload
|
|
121
122
|
const responseNonceAsBase64 = Utils.toBase64(responseReader.read(32))
|
|
122
|
-
if (responseNonceAsBase64
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
123
|
+
if (responseNonceAsBase64 !== requestNonceAsBase64) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
peerToUse.peer.stopListeningForGeneralMessages(listenerId)
|
|
127
|
+
|
|
128
|
+
// Save the identity key for the peer for future requests, since we have it here.
|
|
129
|
+
this.peers[baseURL].identityKey = senderPublicKey
|
|
130
|
+
this.peers[baseURL].supportsMutualAuth = true
|
|
131
|
+
|
|
132
|
+
// Status code
|
|
133
|
+
const statusCode = responseReader.readVarIntNum()
|
|
134
|
+
|
|
135
|
+
// Headers
|
|
136
|
+
const responseHeaders = {}
|
|
137
|
+
const nHeaders = responseReader.readVarIntNum()
|
|
138
|
+
if (nHeaders > 0) {
|
|
139
|
+
for (let i = 0; i < nHeaders; i++) {
|
|
140
|
+
const nHeaderKeyBytes = responseReader.readVarIntNum()
|
|
141
|
+
const headerKeyBytes = responseReader.read(nHeaderKeyBytes)
|
|
142
|
+
const headerKey = Utils.toUTF8(headerKeyBytes)
|
|
143
|
+
const nHeaderValueBytes = responseReader.readVarIntNum()
|
|
144
|
+
const headerValueBytes = responseReader.read(nHeaderValueBytes)
|
|
145
|
+
const headerValue = Utils.toUTF8(headerValueBytes)
|
|
146
|
+
responseHeaders[headerKey] = headerValue
|
|
145
147
|
}
|
|
148
|
+
}
|
|
146
149
|
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
// Add back the server identity key header
|
|
151
|
+
responseHeaders['x-bsv-auth-identity-key'] = senderPublicKey
|
|
149
152
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
// Body
|
|
154
|
+
let responseBody
|
|
155
|
+
const responseBodyBytes = responseReader.readVarIntNum()
|
|
156
|
+
if (responseBodyBytes > 0) {
|
|
157
|
+
responseBody = responseReader.read(responseBodyBytes)
|
|
158
|
+
}
|
|
156
159
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
// Create the Response object
|
|
161
|
+
const responseValue = new Response(
|
|
162
|
+
responseBody ? new Uint8Array(responseBody) : null,
|
|
163
|
+
{
|
|
160
164
|
status: statusCode,
|
|
161
165
|
statusText: `${statusCode}`,
|
|
162
166
|
headers: new Headers(responseHeaders)
|
|
163
|
-
}
|
|
167
|
+
}
|
|
168
|
+
)
|
|
164
169
|
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
// Resolve or reject the correct request with the response data
|
|
171
|
+
this.callbacks[requestNonceAsBase64].resolve(responseValue)
|
|
167
172
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
173
|
+
// Clean up
|
|
174
|
+
delete this.callbacks[requestNonceAsBase64]
|
|
171
175
|
})
|
|
172
176
|
|
|
173
177
|
// Send the request, now that all listeners are set up
|
|
174
178
|
await peerToUse.peer.toPeer(writer.toArray(), peerToUse.identityKey).catch(async error => {
|
|
179
|
+
if (error.message.includes('Session not found for nonce')) {
|
|
180
|
+
delete this.peers[baseURL]
|
|
181
|
+
config.retryCounter ??= 3
|
|
182
|
+
const response = await this.fetch(url, config)
|
|
183
|
+
resolve(response)
|
|
184
|
+
}
|
|
175
185
|
if (error.message.includes('HTTP server failed to authenticate')) {
|
|
176
186
|
try {
|
|
177
187
|
const response = await this.handleFetchAndValidate(url, config, peerToUse)
|
|
@@ -186,7 +196,7 @@ export class AuthFetch {
|
|
|
186
196
|
} catch (e) {
|
|
187
197
|
reject(e)
|
|
188
198
|
}
|
|
189
|
-
})
|
|
199
|
+
}) as Function)
|
|
190
200
|
|
|
191
201
|
// Check if server requires payment to access the requested route
|
|
192
202
|
if (response.status === 402) {
|