UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

628 lines 29.9 kB
import { SessionManager } from './SessionManager.js'; import { createNonce, verifyNonce, getVerifiableCertificates, validateCertificates } from './utils/index.js'; import Random from '../primitives/Random.js'; import * as Utils from '../primitives/utils.js'; const AUTH_VERSION = '0.1'; /** * Represents a peer capable of performing mutual authentication. * Manages sessions, handles authentication handshakes, certificate requests and responses, * and sending and receiving general messages over a transport layer. * * This version supports multiple concurrent sessions per peer identityKey. */ export class Peer { sessionManager; transport; wallet; certificatesToRequest; onGeneralMessageReceivedCallbacks = new Map(); onCertificatesReceivedCallbacks = new Map(); onCertificateRequestReceivedCallbacks = new Map(); onInitialResponseReceivedCallbacks = new Map(); // Single shared counter for all callback types callbackIdCounter = 0; // Whether to auto-persist the session with the last-interacted-with peer autoPersistLastSession = true; // Last-interacted-with peer identity key (if the user calls toPeer with no identityKey) lastInteractedWithPeer; originator; /** * Creates a new Peer instance * * @param {WalletInterface} wallet - The wallet instance used for cryptographic operations. * @param {Transport} transport - The transport mechanism used for sending and receiving messages. * @param {RequestedCertificateSet} [certificatesToRequest] - Optional set of certificates to request from a peer during the initial handshake. * @param {SessionManager} [sessionManager] - Optional SessionManager to be used for managing peer sessions. * @param {boolean} [autoPersistLastSession] - Whether to auto-persist the session with the last-interacted-with peer. Defaults to true. */ constructor(wallet, transport, certificatesToRequest, sessionManager, autoPersistLastSession, originator) { this.wallet = wallet; this.originator = originator; this.transport = transport; this.certificatesToRequest = certificatesToRequest ?? { certifiers: [], types: {} }; this.transport.onData(this.handleIncomingMessage.bind(this)).catch(e => { throw e; }); this.sessionManager = sessionManager != null ? sessionManager : new SessionManager(); if (autoPersistLastSession === false) { this.autoPersistLastSession = false; } else { this.autoPersistLastSession = true; } } /** * Sends a general message to a peer, and initiates a handshake if necessary. * * @param {number[]} message - The message payload to send. * @param {string} [identityKey] - The identity public key of the peer. If not provided, uses lastInteractedWithPeer (if any). * @param {number} [maxWaitTime] - optional max wait time in ms * @returns {Promise<void>} * @throws Will throw an error if the message fails to send. */ async toPeer(message, identityKey, maxWaitTime) { if (this.autoPersistLastSession && typeof this.lastInteractedWithPeer === 'string' && typeof identityKey !== 'string') { identityKey = this.lastInteractedWithPeer; } const peerSession = await this.getAuthenticatedSession(identityKey, maxWaitTime); // Prepare the general message const requestNonce = Utils.toBase64(Random(32)); const { signature } = await this.wallet.createSignature({ data: message, protocolID: [2, 'auth message signature'], keyID: `${requestNonce} ${peerSession.peerNonce ?? ''}`, counterparty: peerSession.peerIdentityKey }, this.originator); const generalMessage = { version: AUTH_VERSION, messageType: 'general', identityKey: (await this.wallet.getPublicKey({ identityKey: true }, this.originator)) .publicKey, nonce: requestNonce, yourNonce: peerSession.peerNonce, payload: message, signature }; peerSession.lastUpdate = Date.now(); this.sessionManager.updateSession(peerSession); try { await this.transport.send(generalMessage); } catch (error) { const e = new Error(`Failed to send message to peer ${peerSession.peerIdentityKey ?? 'unknown'}: ${String(error.message)}`); e.stack = error.stack; throw e; } } /** * Sends a request for certificates to a peer. * This method allows a peer to dynamically request specific certificates after * an initial handshake or message has been exchanged. * * @param {RequestedCertificateSet} certificatesToRequest - Specifies the certifiers and types of certificates required from the peer. * @param {string} [identityKey] - The identity public key of the peer. If not provided, the current or last session identity is used. * @param {number} [maxWaitTime=10000] - Maximum time in milliseconds to wait for the peer session to be authenticated. * @returns {Promise<void>} Resolves if the certificate request message is successfully sent. * @throws Will throw an error if the peer session is not authenticated or if sending the request fails. */ async requestCertificates(certificatesToRequest, identityKey, maxWaitTime = 10000) { if (this.autoPersistLastSession && typeof this.lastInteractedWithPeer === 'string' && typeof identityKey !== 'string') { identityKey = this.lastInteractedWithPeer; } const peerSession = await this.getAuthenticatedSession(identityKey, maxWaitTime); // Prepare the message const requestNonce = Utils.toBase64(Random(32)); const { signature } = await this.wallet.createSignature({ data: Utils.toArray(JSON.stringify(certificatesToRequest), 'utf8'), protocolID: [2, 'auth message signature'], keyID: `${requestNonce} ${peerSession.peerNonce ?? ''}`, counterparty: peerSession.peerIdentityKey }, this.originator); const certRequestMessage = { version: AUTH_VERSION, messageType: 'certificateRequest', identityKey: (await this.wallet.getPublicKey({ identityKey: true }, this.originator)) .publicKey, nonce: requestNonce, initialNonce: peerSession.sessionNonce, yourNonce: peerSession.peerNonce, requestedCertificates: certificatesToRequest, signature }; // Update last-used timestamp peerSession.lastUpdate = Date.now(); this.sessionManager.updateSession(peerSession); try { await this.transport.send(certRequestMessage); } catch (error) { throw new Error(`Failed to send certificate request message to peer ${peerSession.peerIdentityKey ?? 'unknown'}: ${String(error.message)}`); } } /** * Retrieves an authenticated session for a given peer identity. If no session exists * or the session is not authenticated, initiates a handshake to create or authenticate the session. * * - If `identityKey` is provided, we look up any existing session for that identity key. * - If none is found or not authenticated, we do a new handshake. * - If `identityKey` is not provided, but we have a `lastInteractedWithPeer`, we try that key. * * @param {string} [identityKey] - The identity public key of the peer. * @param {number} [maxWaitTime] - The maximum time in milliseconds to wait for the handshake. * @returns {Promise<PeerSession>} - A promise that resolves with an authenticated `PeerSession`. */ async getAuthenticatedSession(identityKey, maxWaitTime) { if (this.transport === undefined) { throw new Error('Peer transport is not connected!'); } let peerSession; if (typeof identityKey === 'string') { peerSession = this.sessionManager.getSession(identityKey); } // If that session doesn't exist or isn't authenticated, initiate handshake if ((peerSession == null) || !peerSession.isAuthenticated) { // This will create a brand-new session const sessionNonce = await this.initiateHandshake(identityKey, maxWaitTime); // Now retrieve it by the sessionNonce peerSession = this.sessionManager.getSession(sessionNonce); if ((peerSession == null) || !peerSession.isAuthenticated) { throw new Error('Unable to establish mutual authentication with peer!'); } } return peerSession; } /** * Registers a callback to listen for general messages from peers. * * @param {(senderPublicKey: string, payload: number[]) => void} callback - The function to call when a general message is received. * @returns {number} The ID of the callback listener. */ listenForGeneralMessages(callback) { const callbackID = this.callbackIdCounter++; this.onGeneralMessageReceivedCallbacks.set(callbackID, callback); return callbackID; } /** * Removes a general message listener. * * @param {number} callbackID - The ID of the callback to remove. */ stopListeningForGeneralMessages(callbackID) { this.onGeneralMessageReceivedCallbacks.delete(callbackID); } /** * Registers a callback to listen for certificates received from peers. * * @param {(senderPublicKey: string, certs: VerifiableCertificate[]) => void} callback - The function to call when certificates are received. * @returns {number} The ID of the callback listener. */ listenForCertificatesReceived(callback) { const callbackID = this.callbackIdCounter++; this.onCertificatesReceivedCallbacks.set(callbackID, callback); return callbackID; } /** * Cancels and unsubscribes a certificatesReceived listener. * * @param {number} callbackID - The ID of the certificates received callback to cancel. */ stopListeningForCertificatesReceived(callbackID) { this.onCertificatesReceivedCallbacks.delete(callbackID); } /** * Registers a callback to listen for certificates requested from peers. * * @param {(requestedCertificates: RequestedCertificateSet) => void} callback - The function to call when a certificate request is received * @returns {number} The ID of the callback listener. */ listenForCertificatesRequested(callback) { const callbackID = this.callbackIdCounter++; this.onCertificateRequestReceivedCallbacks.set(callbackID, callback); return callbackID; } /** * Cancels and unsubscribes a certificatesRequested listener. * * @param {number} callbackID - The ID of the requested certificates callback to cancel. */ stopListeningForCertificatesRequested(callbackID) { this.onCertificateRequestReceivedCallbacks.delete(callbackID); } /** * Initiates the mutual authentication handshake with a peer. * * @private * @param {string} [identityKey] - The identity public key of the peer. * @param {number} [maxWaitTime=10000] - how long to wait for handshake * @returns {Promise<string>} A promise that resolves to the session nonce. */ async initiateHandshake(identityKey, maxWaitTime = 10000) { const sessionNonce = await createNonce(this.wallet, undefined, this.originator); // Initial request nonce // Create the preliminary session (not yet authenticated) const now = Date.now(); this.sessionManager.addSession({ isAuthenticated: false, sessionNonce, peerIdentityKey: identityKey, lastUpdate: now }); const initialRequest = { version: AUTH_VERSION, messageType: 'initialRequest', identityKey: (await this.wallet.getPublicKey({ identityKey: true }, this.originator)) .publicKey, initialNonce: sessionNonce, requestedCertificates: this.certificatesToRequest }; await this.transport.send(initialRequest); return await this.waitForInitialResponse(sessionNonce, maxWaitTime); } /** * Waits for the initial response from the peer after sending an initial handshake request message. * * @param {string} sessionNonce - The session nonce created in the initial request. * @returns {Promise<string>} A promise that resolves with the session nonce when the initial response is received. */ async waitForInitialResponse(sessionNonce, maxWaitTime = 10000) { return await new Promise((resolve, reject) => { const callbackID = this.listenForInitialResponse(sessionNonce, nonce => { clearTimeout(timeoutHandle); this.stopListeningForInitialResponses(callbackID); resolve(nonce); }); const timeoutHandle = setTimeout(() => { this.stopListeningForInitialResponses(callbackID); reject(new Error('Initial response timed out.')); }, maxWaitTime); }); } /** * Adds a listener for an initial response message matching a specific initial nonce. * * @private * @param {string} sessionNonce - The session nonce to match. * @param {(sessionNonce: string) => void} callback - The callback to invoke when the initial response is received. * @returns {number} The ID of the callback listener. */ listenForInitialResponse(sessionNonce, callback) { const callbackID = this.callbackIdCounter++; this.onInitialResponseReceivedCallbacks.set(callbackID, { callback, sessionNonce }); return callbackID; } /** * Removes a listener for initial responses. * * @private * @param {number} callbackID - The ID of the callback to remove. */ stopListeningForInitialResponses(callbackID) { this.onInitialResponseReceivedCallbacks.delete(callbackID); } /** * Handles incoming messages from the transport. * * @param {AuthMessage} message - The incoming message to process. * @returns {Promise<void>} */ async handleIncomingMessage(message) { if (typeof message.version !== 'string' || message.version !== AUTH_VERSION) { throw new Error(`Invalid or unsupported message auth version! Received: ${message.version}, expected: ${AUTH_VERSION}`); } switch (message.messageType) { case 'initialRequest': await this.processInitialRequest(message); break; case 'initialResponse': await this.processInitialResponse(message); break; case 'certificateRequest': await this.processCertificateRequest(message); break; case 'certificateResponse': await this.processCertificateResponse(message); break; case 'general': await this.processGeneralMessage(message); break; default: throw new Error(`Unknown message type of ${String(message.messageType)} from ${String(message.identityKey)}`); } } /** * Processes an initial request message from a peer. * * @param {AuthMessage} message - The incoming initial request message. */ async processInitialRequest(message) { if (typeof message.identityKey !== 'string' || typeof message.initialNonce !== 'string' || message.initialNonce === '') { throw new Error('Missing required fields in initialRequest message.'); } // Create a new sessionNonce for our side const sessionNonce = await createNonce(this.wallet, undefined, this.originator); const now = Date.now(); // We'll treat this as fully authenticated from *our* perspective (the responding side). this.sessionManager.addSession({ isAuthenticated: true, sessionNonce, peerNonce: message.initialNonce, peerIdentityKey: message.identityKey, lastUpdate: now }); // Possibly handle the peer's requested certs let certificatesToInclude; if ((message.requestedCertificates != null) && Array.isArray(message.requestedCertificates.certifiers) && message.requestedCertificates.certifiers.length > 0) { if (this.onCertificateRequestReceivedCallbacks.size > 0) { // Let the application handle it this.onCertificateRequestReceivedCallbacks.forEach(cb => { cb(message.identityKey, message.requestedCertificates); }); } else { // Attempt to find automatically certificatesToInclude = await getVerifiableCertificates(this.wallet, message.requestedCertificates, message.identityKey, this.originator); } } // Create signature const { signature } = await this.wallet.createSignature({ data: Utils.toArray(message.initialNonce + sessionNonce, 'base64'), protocolID: [2, 'auth message signature'], keyID: `${message.initialNonce} ${sessionNonce}`, counterparty: message.identityKey }, this.originator); const initialResponseMessage = { version: AUTH_VERSION, messageType: 'initialResponse', identityKey: (await this.wallet.getPublicKey({ identityKey: true }, this.originator)) .publicKey, initialNonce: sessionNonce, yourNonce: message.initialNonce, certificates: certificatesToInclude, requestedCertificates: this.certificatesToRequest, signature }; // If we haven't interacted with a peer yet, store this identity as "lastInteracted" if (this.lastInteractedWithPeer === undefined) { this.lastInteractedWithPeer = message.identityKey; } // Send the response await this.transport.send(initialResponseMessage); } /** * Processes an initial response message from a peer. * * @private * @param {AuthMessage} message - The incoming initial response message. * @throws Will throw an error if nonce or signature verification fails. */ async processInitialResponse(message) { const validNonce = await verifyNonce(message.yourNonce, this.wallet, undefined, this.originator); if (!validNonce) { throw new Error(`Initial response nonce verification failed from peer: ${message.identityKey}`); } // This is the session we previously created by calling initiateHandshake const peerSession = this.sessionManager.getSession(message.yourNonce); if (peerSession == null) { throw new Error(`Peer session not found for peer: ${message.identityKey}`); } // Validate message signature const dataToVerify = Utils.toArray((peerSession.sessionNonce ?? '') + (message.initialNonce ?? ''), 'base64'); const { valid } = await this.wallet.verifySignature({ data: dataToVerify, signature: message.signature, protocolID: [2, 'auth message signature'], keyID: `${peerSession.sessionNonce ?? ''} ${message.initialNonce ?? ''}`, counterparty: message.identityKey }, this.originator); if (!valid) { throw new Error(`Unable to verify initial response signature for peer: ${message.identityKey}`); } // Now mark the session as authenticated peerSession.peerNonce = message.initialNonce; peerSession.peerIdentityKey = message.identityKey; peerSession.isAuthenticated = true; peerSession.lastUpdate = Date.now(); this.sessionManager.updateSession(peerSession); // If the handshake had requested certificates, validate them if (this.certificatesToRequest?.certifiers?.length > 0 && message.certificates?.length > 0) { await validateCertificates(this.wallet, message, this.certificatesToRequest, this.originator); // Notify listeners this.onCertificatesReceivedCallbacks.forEach(cb => cb(message.identityKey, message.certificates)); } // Update lastInteractedWithPeer this.lastInteractedWithPeer = message.identityKey; // Let the handshake wait-latch know we got our response this.onInitialResponseReceivedCallbacks.forEach(entry => { if (entry.sessionNonce === peerSession.sessionNonce) { entry.callback(peerSession.sessionNonce); } }); // The peer might also request certificates from us if ((message.requestedCertificates != null) && Array.isArray(message.requestedCertificates.certifiers) && message.requestedCertificates.certifiers.length > 0) { if (this.onCertificateRequestReceivedCallbacks.size > 0) { // Let the application handle it this.onCertificateRequestReceivedCallbacks.forEach(cb => { cb(message.identityKey, message.requestedCertificates); }); } else { // Attempt auto const verifiableCertificates = await getVerifiableCertificates(this.wallet, message.requestedCertificates, message.identityKey, this.originator); await this.sendCertificateResponse(message.identityKey, verifiableCertificates); } } } /** * Processes an incoming certificate request message from a peer. * Verifies nonce/signature and then possibly sends a certificateResponse. * * @param {AuthMessage} message - The certificate request message received from the peer. * @throws {Error} if nonce or signature is invalid. */ async processCertificateRequest(message) { const validNonce = await verifyNonce(message.yourNonce, this.wallet, undefined, this.originator); if (!validNonce) { throw new Error(`Unable to verify nonce for certificate request message from: ${message.identityKey}`); } const peerSession = this.sessionManager.getSession(message.yourNonce); if (peerSession == null) { throw new Error(`Session not found for nonce: ${message.yourNonce}`); } const { valid } = await this.wallet.verifySignature({ data: Utils.toArray(JSON.stringify(message.requestedCertificates), 'utf8'), signature: message.signature, protocolID: [2, 'auth message signature'], keyID: `${message.nonce ?? ''} ${peerSession.sessionNonce ?? ''}`, counterparty: peerSession.peerIdentityKey }, this.originator); if (!valid) { throw new Error(`Invalid signature in certificate request message from ${peerSession.peerIdentityKey}`); } // Update usage peerSession.lastUpdate = Date.now(); this.sessionManager.updateSession(peerSession); if ((message.requestedCertificates != null) && Array.isArray(message.requestedCertificates.certifiers) && message.requestedCertificates.certifiers.length > 0) { if (this.onCertificateRequestReceivedCallbacks.size > 0) { // Let the application handle it this.onCertificateRequestReceivedCallbacks.forEach(cb => { cb(message.identityKey, message.requestedCertificates); }); } else { // Attempt auto const verifiableCertificates = await getVerifiableCertificates(this.wallet, message.requestedCertificates, message.identityKey, this.originator); await this.sendCertificateResponse(message.identityKey, verifiableCertificates); } } } /** * Sends a certificate response message containing the specified certificates to a peer. * * @param {string} verifierIdentityKey - The identity key of the peer requesting the certificates. * @param {VerifiableCertificate[]} certificates - The list of certificates to include in the response. * @throws Will throw an error if the transport fails to send the message. */ async sendCertificateResponse(verifierIdentityKey, certificates) { const peerSession = await this.getAuthenticatedSession(verifierIdentityKey); const requestNonce = Utils.toBase64(Random(32)); const { signature } = await this.wallet.createSignature({ data: Utils.toArray(JSON.stringify(certificates), 'utf8'), protocolID: [2, 'auth message signature'], keyID: `${requestNonce} ${peerSession.peerNonce ?? ''}`, counterparty: peerSession.peerIdentityKey }, this.originator); const certificateResponse = { version: AUTH_VERSION, messageType: 'certificateResponse', identityKey: (await this.wallet.getPublicKey({ identityKey: true }, this.originator)) .publicKey, nonce: requestNonce, initialNonce: peerSession.sessionNonce, yourNonce: peerSession.peerNonce, certificates, signature }; // Update usage peerSession.lastUpdate = Date.now(); this.sessionManager.updateSession(peerSession); try { await this.transport.send(certificateResponse); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to send certificate response message to peer ${peerSession.peerIdentityKey ?? 'unknown'}: ${errorMessage}`); } } /** * Processes a certificate response message from a peer. * * @private * @param {AuthMessage} message - The incoming certificate response message. * @throws Will throw an error if nonce verification or signature verification fails. */ async processCertificateResponse(message) { const validNonce = await verifyNonce(message.yourNonce, this.wallet, undefined, this.originator); if (!validNonce) { throw new Error(`Unable to verify nonce for certificate response from: ${message.identityKey}`); } const peerSession = this.sessionManager.getSession(message.yourNonce); if (peerSession == null) { throw new Error(`Session not found for nonce: ${message.yourNonce}`); } // Validate message signature const { valid } = await this.wallet.verifySignature({ data: Utils.toArray(JSON.stringify(message.certificates), 'utf8'), signature: message.signature, protocolID: [2, 'auth message signature'], keyID: `${message.nonce ?? ''} ${peerSession.sessionNonce ?? ''}`, counterparty: message.identityKey }, this.originator); if (!valid) { throw new Error(`Unable to verify certificate response signature for peer: ${message.identityKey}`); } // We also handle optional validation if there's a requestedCertificates field await validateCertificates(this.wallet, message, message.requestedCertificates, this.originator); // Notify any listeners this.onCertificatesReceivedCallbacks.forEach(cb => { cb(message.identityKey, message.certificates ?? []); }); peerSession.lastUpdate = Date.now(); this.sessionManager.updateSession(peerSession); } /** * Processes a general message from a peer. * * @private * @param {AuthMessage} message - The incoming general message. * @throws Will throw an error if nonce or signature verification fails. */ async processGeneralMessage(message) { const validNonce = await verifyNonce(message.yourNonce, this.wallet, undefined, this.originator); if (!validNonce) { throw new Error(`Unable to verify nonce for general message from: ${message.identityKey}`); } const peerSession = this.sessionManager.getSession(message.yourNonce); if (peerSession == null) { throw new Error(`Session not found for nonce: ${message.yourNonce}`); } const { valid } = await this.wallet.verifySignature({ data: message.payload, signature: message.signature, protocolID: [2, 'auth message signature'], keyID: `${message.nonce ?? ''} ${peerSession.sessionNonce ?? ''}`, counterparty: peerSession.peerIdentityKey }, this.originator); if (!valid) { throw new Error(`Invalid signature in generalMessage from ${peerSession.peerIdentityKey}`); } // Mark last usage peerSession.lastUpdate = Date.now(); this.sessionManager.updateSession(peerSession); // Update lastInteractedWithPeer this.lastInteractedWithPeer = message.identityKey; // Dispatch callbacks this.onGeneralMessageReceivedCallbacks.forEach(cb => { cb(message.identityKey, message.payload ?? []); }); } } //# sourceMappingURL=Peer.js.map