@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.
Files changed (32) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/Peer.js +142 -91
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/auth/SessionManager.js +82 -21
  5. package/dist/cjs/src/auth/SessionManager.js.map +1 -1
  6. package/dist/cjs/src/auth/clients/AuthFetch.js +50 -41
  7. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/esm/src/auth/Peer.js +138 -88
  10. package/dist/esm/src/auth/Peer.js.map +1 -1
  11. package/dist/esm/src/auth/SessionManager.js +88 -22
  12. package/dist/esm/src/auth/SessionManager.js.map +1 -1
  13. package/dist/esm/src/auth/clients/AuthFetch.js +49 -41
  14. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  15. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  16. package/dist/types/src/auth/Peer.d.ts +21 -23
  17. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  18. package/dist/types/src/auth/SessionManager.d.ts +25 -7
  19. package/dist/types/src/auth/SessionManager.d.ts.map +1 -1
  20. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  21. package/dist/types/src/auth/types.d.ts +1 -0
  22. package/dist/types/src/auth/types.d.ts.map +1 -1
  23. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  24. package/dist/umd/bundle.js +1 -1
  25. package/docs/auth.md +33 -38
  26. package/package.json +1 -1
  27. package/src/auth/Peer.ts +186 -130
  28. package/src/auth/SessionManager.ts +89 -22
  29. package/src/auth/__tests/Peer.test.ts +66 -0
  30. package/src/auth/__tests/SessionManager.test.ts +3 -2
  31. package/src/auth/clients/AuthFetch.ts +53 -43
  32. 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 sessions to be added, retrieved, updated, and removed
5
- * by relevant identifiers (sessionNonce and peerIdentityKey).
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
- private readonly identifierToSession: Map<string, PeerSession>
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.identifierToSession = new Map<string, PeerSession>()
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 relevant identifiers for retrieval.
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 ((session.sessionNonce === null || session.sessionNonce === undefined || session.sessionNonce === '') &&
21
- (session.peerIdentityKey === null || session.peerIdentityKey === undefined || session.peerIdentityKey === '')) {
33
+ if (typeof session.sessionNonce !== 'string') {
22
34
  throw new Error(
23
- 'Invalid session: at least one of sessionNonce or peerIdentityKey is required.'
35
+ 'Invalid session: sessionNonce is required to add a session.'
24
36
  )
25
37
  }
26
38
 
27
- if (session.sessionNonce !== null && session.sessionNonce !== undefined && session.sessionNonce !== '') {
28
- this.identifierToSession.set(session.sessionNonce, session)
29
- }
30
- if (session.peerIdentityKey !== null && session.peerIdentityKey !== undefined && session.peerIdentityKey !== '') {
31
- this.identifierToSession.set(session.peerIdentityKey, session)
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, ensuring that all identifiers are correctly associated.
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
- return this.identifierToSession.get(identifier)
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 (session.sessionNonce !== null && session.sessionNonce !== undefined && session.sessionNonce !== '') {
62
- this.identifierToSession.delete(session.sessionNonce)
118
+ if (typeof session.sessionNonce === 'string') {
119
+ this.sessionNonceToSession.delete(session.sessionNonce)
63
120
  }
64
- if (session.peerIdentityKey !== null && session.peerIdentityKey !== undefined && session.peerIdentityKey !== '') {
65
- this.identifierToSession.delete(session.peerIdentityKey)
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 based on a given identifier.
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
- return this.identifierToSession.has(identifier)
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: at least one of sessionNonce or peerIdentityKey is required.'
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 (!this.peers[baseURL]) {
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 === requestNonceAsBase64) {
123
- peerToUse.peer.stopListeningForGeneralMessages(listenerId)
124
-
125
- // Save the identity key for the peer for future requests, since we have it here.
126
- this.peers[baseURL].identityKey = senderPublicKey
127
- this.peers[baseURL].supportsMutualAuth = true
128
-
129
- // Status code
130
- const statusCode = responseReader.readVarIntNum()
131
-
132
- // Headers
133
- const responseHeaders = {}
134
- const nHeaders = responseReader.readVarIntNum()
135
- if (nHeaders > 0) {
136
- for (let i = 0; i < nHeaders; i++) {
137
- const nHeaderKeyBytes = responseReader.readVarIntNum()
138
- const headerKeyBytes = responseReader.read(nHeaderKeyBytes)
139
- const headerKey = Utils.toUTF8(headerKeyBytes)
140
- const nHeaderValueBytes = responseReader.readVarIntNum()
141
- const headerValueBytes = responseReader.read(nHeaderValueBytes)
142
- const headerValue = Utils.toUTF8(headerValueBytes)
143
- responseHeaders[headerKey] = headerValue
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
- // Add back the server identity key header
148
- responseHeaders['x-bsv-auth-identity-key'] = senderPublicKey
150
+ // Add back the server identity key header
151
+ responseHeaders['x-bsv-auth-identity-key'] = senderPublicKey
149
152
 
150
- // Body
151
- let responseBody
152
- const responseBodyBytes = responseReader.readVarIntNum()
153
- if (responseBodyBytes > 0) {
154
- responseBody = responseReader.read(responseBodyBytes)
155
- }
153
+ // Body
154
+ let responseBody
155
+ const responseBodyBytes = responseReader.readVarIntNum()
156
+ if (responseBodyBytes > 0) {
157
+ responseBody = responseReader.read(responseBodyBytes)
158
+ }
156
159
 
157
- // Create the Response object
158
- const responseValue = new Response(
159
- responseBody ? new Uint8Array(responseBody) : null, {
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
- // Resolve or reject the correct request with the response data
166
- this.callbacks[requestNonceAsBase64].resolve(responseValue)
170
+ // Resolve or reject the correct request with the response data
171
+ this.callbacks[requestNonceAsBase64].resolve(responseValue)
167
172
 
168
- // Clean up
169
- delete this.callbacks[requestNonceAsBase64]
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) {
package/src/auth/types.ts CHANGED
@@ -38,4 +38,5 @@ export interface PeerSession {
38
38
  sessionNonce?: string
39
39
  peerNonce?: string
40
40
  peerIdentityKey?: string
41
+ lastUpdate: number
41
42
  }