UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

482 lines 23.5 kB
// @ts-nocheck import * as Utils from '../../primitives/utils.js'; import Random from '../../primitives/Random.js'; import P2PKH from '../../script/templates/P2PKH.js'; import PublicKey from '../../primitives/PublicKey.js'; import { createNonce } from '../utils/createNonce.js'; import { Peer } from '../Peer.js'; import { SimplifiedFetchTransport } from '../transports/SimplifiedFetchTransport.js'; import { SessionManager } from '../SessionManager.js'; import { getVerifiableCertificates } from '../utils/index.js'; const PAYMENT_VERSION = '1.0'; /** * AuthFetch provides a lightweight fetch client for interacting with servers * over a simplified HTTP transport mechanism. It integrates session management, peer communication, * and certificate handling to enable secure and mutually-authenticated requests. * * Additionally, it automatically handles 402 Payment Required responses by creating * and sending BSV payment transactions when necessary. */ export class AuthFetch { sessionManager; wallet; callbacks = {}; certificatesReceived = []; requestedCertificates; originator; peers = {}; /** * Constructs a new AuthFetch instance. * @param wallet - The wallet instance for signing and authentication. * @param requestedCertificates - Optional set of certificates to request from peers. */ constructor(wallet, requestedCertificates, sessionManager, originator) { this.wallet = wallet; this.requestedCertificates = requestedCertificates; this.sessionManager = sessionManager ?? new SessionManager(); this.originator = originator; } /** * Mutually authenticates and sends a HTTP request to a server. * * 1) Attempt the request. * 2) If 402 Payment Required, automatically create and send payment. * 3) Return the final response. * * @param url - The URL to send the request to. * @param config - Configuration options for the request, including method, headers, and body. * @returns A promise that resolves with the server's response, structured as a Response-like object. * * @throws Will throw an error if unsupported headers are used or other validation fails. */ async fetch(url, config = {}) { if (typeof config.retryCounter === 'number') { if (config.retryCounter <= 0) { throw new Error('Request failed after maximum number of retries.'); } config.retryCounter--; } const response = await new Promise((async (resolve, reject) => { try { // Apply defaults const { method = 'GET', headers = {}, body } = config; // Extract a base url const parsedUrl = new URL(url); const baseURL = parsedUrl.origin; // Create a new transport for this base url if needed let peerToUse; if (typeof this.peers[baseURL] === 'undefined') { // Create a peer for the request const newTransport = new SimplifiedFetchTransport(baseURL); peerToUse = { peer: new Peer(this.wallet, newTransport, this.requestedCertificates, this.sessionManager, undefined, this.originator), pendingCertificateRequests: [] }; this.peers[baseURL] = peerToUse; this.peers[baseURL].peer.listenForCertificatesReceived((senderPublicKey, certs) => { this.certificatesReceived.push(...certs); }); this.peers[baseURL].peer.listenForCertificatesRequested((async (verifier, requestedCertificates) => { try { this.peers[baseURL].pendingCertificateRequests.push(true); const certificatesToInclude = await getVerifiableCertificates(this.wallet, requestedCertificates, verifier, this.originator); await this.peers[baseURL].peer.sendCertificateResponse(verifier, certificatesToInclude); } finally { // Give the backend 500 ms to process the certificates we just sent, before releasing the queue entry await new Promise(resolve => setTimeout(resolve, 500)); this.peers[baseURL].pendingCertificateRequests.shift(); } })); } else { // Check if there's a session associated with this baseURL if (this.peers[baseURL].supportsMutualAuth === false) { // Use standard fetch if mutual authentication is not supported try { const response = await this.handleFetchAndValidate(url, config, this.peers[baseURL]); resolve(response); } catch (error) { reject(error); } return; } peerToUse = this.peers[baseURL]; } // Serialize the simplified fetch request. const requestNonce = Random(32); const requestNonceAsBase64 = Utils.toBase64(requestNonce); const writer = await this.serializeRequest(method, headers, body, parsedUrl, requestNonce); // Setup general message listener to resolve requests once a response is received this.callbacks[requestNonceAsBase64] = { resolve, reject }; const listenerId = peerToUse.peer.listenForGeneralMessages((senderPublicKey, payload) => { // Create a reader const responseReader = new Utils.Reader(payload); // Deserialize first 32 bytes of payload const responseNonceAsBase64 = Utils.toBase64(responseReader.read(32)); if (responseNonceAsBase64 !== requestNonceAsBase64) { return; } peerToUse.peer.stopListeningForGeneralMessages(listenerId); // Save the identity key for the peer for future requests, since we have it here. this.peers[baseURL].identityKey = senderPublicKey; this.peers[baseURL].supportsMutualAuth = true; // Status code const statusCode = responseReader.readVarIntNum(); // Headers const responseHeaders = {}; const nHeaders = responseReader.readVarIntNum(); if (nHeaders > 0) { for (let i = 0; i < nHeaders; i++) { const nHeaderKeyBytes = responseReader.readVarIntNum(); const headerKeyBytes = responseReader.read(nHeaderKeyBytes); const headerKey = Utils.toUTF8(headerKeyBytes); const nHeaderValueBytes = responseReader.readVarIntNum(); const headerValueBytes = responseReader.read(nHeaderValueBytes); const headerValue = Utils.toUTF8(headerValueBytes); responseHeaders[headerKey] = headerValue; } } // Add back the server identity key header responseHeaders['x-bsv-auth-identity-key'] = senderPublicKey; // Body let responseBody; const responseBodyBytes = responseReader.readVarIntNum(); if (responseBodyBytes > 0) { responseBody = responseReader.read(responseBodyBytes); } // Create the Response object const responseValue = new Response(responseBody ? new Uint8Array(responseBody) : null, { status: statusCode, statusText: `${statusCode}`, headers: new Headers(responseHeaders) }); // Resolve or reject the correct request with the response data this.callbacks[requestNonceAsBase64].resolve(responseValue); // Clean up delete this.callbacks[requestNonceAsBase64]; }); // Before sending general messages to the peer, ensure that no certificate requests are pending. // This way, the user would need to choose to either allow or reject the certificate request first. // If the server has a resource that requires certificates to be sent before access would be granted, // this makes sure the user has a chance to send the certificates before the resource is requested. if (peerToUse.pendingCertificateRequests.length > 0) { await new Promise(resolve => { setInterval(() => { if (peerToUse.pendingCertificateRequests.length === 0) { resolve(); } }, 100); // Check every 100 ms for the user to finish responding }); } // Send the request, now that all listeners are set up await peerToUse.peer.toPeer(writer.toArray(), peerToUse.identityKey).catch(async (error) => { if (error.message.includes('Session not found for nonce')) { delete this.peers[baseURL]; config.retryCounter ??= 3; const response = await this.fetch(url, config); resolve(response); return; } if (error.message.includes('HTTP server failed to authenticate')) { try { const response = await this.handleFetchAndValidate(url, config, peerToUse); resolve(response); return; } catch (fetchError) { reject(fetchError); } } else { reject(error); } }); } catch (e) { reject(e); } })); // Check if server requires payment to access the requested route if (response.status === 402) { // Create and attach a payment, then retry return await this.handlePaymentAndRetry(url, config, response); } return response; } /** * Request Certificates from a Peer * @param baseUrl * @param certificatesToRequest */ async sendCertificateRequest(baseUrl, certificatesToRequest) { const parsedUrl = new URL(baseUrl); const baseURL = parsedUrl.origin; let peerToUse; if (typeof this.peers[baseURL] !== 'undefined') { peerToUse = { peer: this.peers[baseURL].peer }; } else { const newTransport = new SimplifiedFetchTransport(baseURL); peerToUse = { peer: new Peer(this.wallet, newTransport, this.requestedCertificates, this.sessionManager, this.originator) }; this.peers[baseURL] = peerToUse; } // Return a promise that resolves when certificates are received return await new Promise((async (resolve, reject) => { // Set up the listener before making the request const callbackId = peerToUse.peer.listenForCertificatesReceived((_senderPublicKey, certs) => { peerToUse.peer.stopListeningForCertificatesReceived(callbackId); this.certificatesReceived.push(...certs); resolve(certs); }); try { // Initiate the certificate request await peerToUse.peer.requestCertificates(certificatesToRequest, peerToUse.identityKey); } catch (err) { peerToUse.peer.stopListeningForCertificatesReceived(callbackId); reject(err); } })); } /** * Return any certificates we've collected thus far, then clear them out. */ consumeReceivedCertificates() { return this.certificatesReceived.splice(0); } /** * Serializes the HTTP request to be sent over the Transport. * * @param method - The HTTP method (e.g., 'GET', 'POST') for the request. * @param headers - A record of HTTP headers to include in the request. * @param body - The body of the request, if applicable (e.g., for POST/PUT requests). * @param parsedUrl - The parsed URL object containing the full request URL. * @param requestNonce - A unique random nonce to ensure request integrity. * @returns A promise that resolves to a `Writer` containing the serialized request. * * @throws Will throw an error if unsupported headers are used or serialization fails. */ async serializeRequest(method, headers, body, parsedUrl, requestNonce) { const writer = new Utils.Writer(); // Write request nonce writer.write(requestNonce); // Method length writer.writeVarIntNum(method.length); // Method writer.write(Utils.toArray(method)); // Handle pathname (e.g. /path/to/resource) if (parsedUrl.pathname.length > 0) { // Pathname length const pathnameAsArray = Utils.toArray(parsedUrl.pathname); writer.writeVarIntNum(pathnameAsArray.length); // Pathname writer.write(pathnameAsArray); } else { writer.writeVarIntNum(-1); } // Handle search params (e.g. ?q=hello) if (parsedUrl.search.length > 0) { // search length const searchAsArray = Utils.toArray(parsedUrl.search); writer.writeVarIntNum(searchAsArray.length); // search writer.write(searchAsArray); } else { writer.writeVarIntNum(-1); } // Construct headers to send / sign: // Ensures clients only provided supported HTTP request headers // - Include custom headers prefixed with x-bsv (excluding those starting with x-bsv-auth) // - Include a normalized version of the content-type header // - Include the authorization header const includedHeaders = []; for (let [k, v] of Object.entries(headers)) { k = k.toLowerCase(); // We will always sign lower-case header keys if (k.startsWith('x-bsv-') || k === 'authorization') { if (k.startsWith('x-bsv-auth')) { throw new Error('No BSV auth headers allowed here!'); } includedHeaders.push([k, v]); } else if (k.startsWith('content-type')) { // Normalize the Content-Type header by removing any parameters (e.g., "; charset=utf-8") v = v.split(';')[0].trim(); includedHeaders.push([k, v]); } else { throw new Error('Unsupported header in the simplified fetch implementation. Only content-type, authorization, and x-bsv-* headers are supported.'); } } // Sort the headers by key to ensure a consistent order for signing and verification. includedHeaders.sort(([keyA], [keyB]) => keyA.localeCompare(keyB)); // nHeaders writer.writeVarIntNum(includedHeaders.length); for (let i = 0; i < includedHeaders.length; i++) { // headerKeyLength const headerKeyAsArray = Utils.toArray(includedHeaders[i][0], 'utf8'); writer.writeVarIntNum(headerKeyAsArray.length); // headerKey writer.write(headerKeyAsArray); // headerValueLength const headerValueAsArray = Utils.toArray(includedHeaders[i][1], 'utf8'); writer.writeVarIntNum(headerValueAsArray.length); // headerValue writer.write(headerValueAsArray); } // If method typically carries a body and body is undefined, default it // This prevents signature verification errors due to mismatch default body types with express const methodsThatTypicallyHaveBody = ['POST', 'PUT', 'PATCH', 'DELETE']; if (methodsThatTypicallyHaveBody.includes(method.toUpperCase()) && body === undefined) { // Check if content-type is application/json const contentTypeHeader = includedHeaders.find(([k]) => k === 'content-type'); if (contentTypeHeader && contentTypeHeader[1].includes('application/json')) { body = '{}'; } else { body = ''; } } // Handle body if (body) { const reqBody = await this.normalizeBodyToNumberArray(body); // Use the utility function writer.writeVarIntNum(reqBody.length); writer.write(reqBody); } else { writer.writeVarIntNum(-1); // No body } return writer; } /** * Handles a non-authenticated fetch requests and validates that the server is not claiming to be authenticated. */ async handleFetchAndValidate(url, config, peerToUse) { const response = await fetch(url, config); response.headers.forEach(header => { if (header.toLocaleLowerCase().startsWith('x-bsv')) { throw new Error('The server is trying to claim it has been authenticated when it has not!'); } }); if (response.ok) { peerToUse.supportsMutualAuth = false; return response; } else { throw new Error(`Request failed with status: ${response.status}`); } } /** * If we get 402 Payment Required, we build a transaction via wallet.createAction() * and re-attempt the request with an x-bsv-payment header. */ async handlePaymentAndRetry(url, config = {}, originalResponse) { // Make sure the server is using the correct payment version const paymentVersion = originalResponse.headers.get('x-bsv-payment-version'); if (!paymentVersion || paymentVersion !== PAYMENT_VERSION) { throw new Error(`Unsupported x-bsv-payment-version response header. Client version: ${PAYMENT_VERSION}, Server version: ${paymentVersion}`); } // Get required headers from the 402 response const satoshisRequiredHeader = originalResponse.headers.get('x-bsv-payment-satoshis-required'); if (!satoshisRequiredHeader) { throw new Error('Missing x-bsv-payment-satoshis-required response header.'); } const satoshisRequired = parseInt(satoshisRequiredHeader); if (isNaN(satoshisRequired) || satoshisRequired <= 0) { throw new Error('Invalid x-bsv-payment-satoshis-required response header value.'); } const serverIdentityKey = originalResponse.headers.get('x-bsv-auth-identity-key'); if (typeof serverIdentityKey !== 'string') { throw new Error('Missing x-bsv-auth-identity-key response header.'); } const derivationPrefix = originalResponse.headers.get('x-bsv-payment-derivation-prefix'); if (typeof derivationPrefix !== 'string' || derivationPrefix.length < 1) { throw new Error('Missing x-bsv-payment-derivation-prefix response header.'); } // Create a random suffix for the derivation path const derivationSuffix = await createNonce(this.wallet, undefined, this.originator); // Derive the script hex from the server identity key const { publicKey: derivedPublicKey } = await this.wallet.getPublicKey({ protocolID: [2, '3241645161d8'], keyID: `${derivationPrefix} ${derivationSuffix}`, counterparty: serverIdentityKey }, this.originator); const lockingScript = new P2PKH().lock(PublicKey.fromString(derivedPublicKey).toAddress()).toHex(); // Create the payment transaction using createAction const { tx } = await this.wallet.createAction({ description: `Payment for request to ${new URL(url).origin}`, outputs: [{ satoshis: satoshisRequired, lockingScript, customInstructions: JSON.stringify({ derivationPrefix, derivationSuffix, payee: serverIdentityKey }), outputDescription: 'HTTP request payment' }], options: { randomizeOutputs: false } }, this.originator); // Attach the payment to the request headers config.headers = config.headers || {}; config.headers['x-bsv-payment'] = JSON.stringify({ derivationPrefix, derivationSuffix, transaction: Utils.toBase64(tx) }); config.retryCounter ??= 3; // Re-attempt request with payment attached return this.fetch(url, config); } async normalizeBodyToNumberArray(body) { // 0. Null / undefined if (body == null) { return []; } // 1. object if (typeof body === 'object') { return Utils.toArray(JSON.stringify(body, 'utf8')); } // 2. number[] if (Array.isArray(body) && body.every((item) => typeof item === 'number')) { return body; // Return the array as is } // 3. string if (typeof body === 'string') { return Utils.toArray(body, 'utf8'); } // 4. ArrayBuffer / TypedArrays if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { const typedArray = body instanceof ArrayBuffer ? new Uint8Array(body) : new Uint8Array(body.buffer); return Array.from(typedArray); } // 5. Blob if (body instanceof Blob) { const arrayBuffer = await body.arrayBuffer(); return Array.from(new Uint8Array(arrayBuffer)); } // 6. FormData if (body instanceof FormData) { const entries = []; body.forEach((value, key) => { entries.push([key, value.toString()]); }); const urlEncoded = new URLSearchParams(entries).toString(); return Utils.toArray(urlEncoded, 'utf8'); } // 7. URLSearchParams if (body instanceof URLSearchParams) { return Utils.toArray(body.toString(), 'utf8'); } // 8. ReadableStream if (body instanceof ReadableStream) { throw new Error('ReadableStream cannot be directly converted to number[].'); } // 9. Fallback throw new Error('Unsupported body type in this SimplifiedFetch implementation.'); } } //# sourceMappingURL=AuthFetch.js.map