@bsv/sdk
Version:
BSV Blockchain Software Development Kit
289 lines (260 loc) • 11.8 kB
text/typescript
// @ts-nocheck
// @ts-expect-error
import { AuthMessage, RequestedCertificateSet, Transport } from '../types.js'
import * as Utils from '../../primitives/utils.js'
// Only bind window.fetch in the browser
const defaultFetch = typeof window !== 'undefined' ? fetch.bind(window) : fetch
/**
* Implements an HTTP-specific transport for handling Peer mutual authentication messages.
* This class integrates with fetch to send and receive authenticated messages between peers.
*/
export class SimplifiedFetchTransport implements Transport {
private onDataCallback?: (message: AuthMessage) => void
fetchClient: typeof fetch
baseUrl: string
/**
* Constructs a new instance of SimplifiedFetchTransport.
* @param baseUrl - The base URL for all HTTP requests made by this transport.
* @param fetchClient - A fetch implementation to use for HTTP requests (default: global fetch).
*/
constructor (baseUrl: string, fetchClient = defaultFetch) {
this.fetchClient = fetchClient
this.baseUrl = baseUrl
}
/**
* Sends a message to an HTTP server using the transport mechanism.
* Handles both general and authenticated message types. For general messages,
* the payload is deserialized and sent as an HTTP request. For other message types,
* the message is sent as a POST request to the `/auth` endpoint.
*
* @param message - The AuthMessage to send.
* @returns A promise that resolves when the message is successfully sent.
*
* @throws Will throw an error if no listener has been registered via `onData`.
*/
async send (message: AuthMessage): Promise<void> {
if (this.onDataCallback == null) {
throw new Error('Listen before you start speaking. God gave you two ears and one mouth for a reason.')
}
if (message.messageType !== 'general') {
return await new Promise((resolve, reject) => {
void (async () => {
try {
const responsePromise = this.fetchClient(`${this.baseUrl}/.well-known/auth`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(message)
})
// For initialRequest message, mark connection as established and start pool.
if (message.messageType !== 'initialRequest') {
resolve()
}
const response = await responsePromise
// Handle the response if data is received and callback is set
if (response.ok && (this.onDataCallback != null)) {
const responseMessage = await response.json()
this.onDataCallback(responseMessage as AuthMessage)
} else {
// Server may be a non authenticated server
throw new Error('HTTP server failed to authenticate')
}
if (message.messageType === 'initialRequest') {
resolve()
}
} catch (e) {
reject(e)
}
})()
})
} else {
// Parse message payload
const httpRequest = this.deserializeRequestPayload(message.payload)
// Send the byte array as the HTTP payload
const url = `${this.baseUrl}${httpRequest.urlPostfix}`
const httpRequestWithAuthHeaders: any = httpRequest
if (typeof httpRequest.headers !== 'object') {
httpRequestWithAuthHeaders.headers = {}
}
// Append auth headers in request to server
httpRequestWithAuthHeaders.headers['x-bsv-auth-version'] = message.version
httpRequestWithAuthHeaders.headers['x-bsv-auth-identity-key'] = message.identityKey
httpRequestWithAuthHeaders.headers['x-bsv-auth-nonce'] = message.nonce
httpRequestWithAuthHeaders.headers['x-bsv-auth-your-nonce'] = message.yourNonce
httpRequestWithAuthHeaders.headers['x-bsv-auth-signature'] = Utils.toHex(message.signature)
httpRequestWithAuthHeaders.headers['x-bsv-auth-request-id'] = httpRequest.requestId
// Ensure Content-Type is set for requests with a body
if (httpRequestWithAuthHeaders.body != null) {
const headers = httpRequestWithAuthHeaders.headers
if (headers['content-type'] == null) {
throw new Error('Content-Type header is required for requests with a body.')
}
const contentType = String(headers['content-type'] ?? '')
// Transform body based on Content-Type
if (contentType.includes('application/json')) {
// Convert byte array to JSON string
httpRequestWithAuthHeaders.body = Utils.toUTF8(httpRequestWithAuthHeaders.body)
} else if (contentType.includes('application/x-www-form-urlencoded')) {
// Convert byte array to URL-encoded string
httpRequestWithAuthHeaders.body = Utils.toUTF8(httpRequestWithAuthHeaders.body)
} else if (contentType.includes('text/plain')) {
// Convert byte array to plain UTF-8 string
httpRequestWithAuthHeaders.body = Utils.toUTF8(httpRequestWithAuthHeaders.body)
} else {
// For all other content types, treat as binary data
httpRequestWithAuthHeaders.body = new Uint8Array(httpRequestWithAuthHeaders.body)
}
}
// Send the actual fetch request to the server
const response = await this.fetchClient(url, {
method: httpRequestWithAuthHeaders.method,
headers: httpRequestWithAuthHeaders.headers,
body: httpRequestWithAuthHeaders.body
})
// Check for an acceptable status
if (response.status === 500 && (response.headers.get('x-bsv-auth-request-id') == null &&
response.headers.get('x-bsv-auth-requested-certificates') == null)) {
// Try parsing JSON error
const errorInfo = await response.json()
// Otherwise just throw whatever we got
throw new Error(`HTTP ${response.status} - ${JSON.stringify(errorInfo)}`)
}
const parsedBody = await response.arrayBuffer()
const payloadWriter = new Utils.Writer()
if (response.headers.get('x-bsv-auth-request-id') != null) {
payloadWriter.write(Utils.toArray(response.headers.get('x-bsv-auth-request-id'), 'base64'))
}
payloadWriter.writeVarIntNum(response.status)
// PARSE RESPONSE HEADERS FROM SERVER --------------------------------
// Parse response headers from the server and include only the signed headers:
// - Include custom headers prefixed with x-bsv (excluding those starting with x-bsv-auth)
// - Include the authorization header
const includedHeaders: Array<[string, string]> = []
response.headers.forEach((value, key) => {
const lowerKey = key.toLowerCase()
if ((lowerKey.startsWith('x-bsv-') || lowerKey === 'authorization') && !lowerKey.startsWith('x-bsv-auth')) {
includedHeaders.push([lowerKey, value])
}
})
// Sort the headers by key to ensure a consistent order for signing and verification.
includedHeaders.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
// nHeaders
payloadWriter.writeVarIntNum(includedHeaders.length)
for (let i = 0; i < includedHeaders.length; i++) {
// headerKeyLength
const headerKeyAsArray = Utils.toArray(includedHeaders[i][0], 'utf8')
payloadWriter.writeVarIntNum(headerKeyAsArray.length)
// headerKey
payloadWriter.write(headerKeyAsArray)
// headerValueLength
const headerValueAsArray = Utils.toArray(includedHeaders[i][1], 'utf8')
payloadWriter.writeVarIntNum(headerValueAsArray.length)
// headerValue
payloadWriter.write(headerValueAsArray)
}
// Handle body
if (parsedBody != null) {
const bodyAsArray = Array.from(new Uint8Array(parsedBody))
payloadWriter.writeVarIntNum(bodyAsArray.length)
payloadWriter.write(bodyAsArray)
} else {
payloadWriter.writeVarIntNum(-1)
}
// Build the correct AuthMessage for the response
const responseMessage: AuthMessage = {
version: response.headers.get('x-bsv-auth-version'),
messageType: response.headers.get('x-bsv-auth-message-type') === 'certificateRequest' ? 'certificateRequest' : 'general',
identityKey: response.headers.get('x-bsv-auth-identity-key'),
nonce: response.headers.get('x-bsv-auth-nonce'),
yourNonce: response.headers.get('x-bsv-auth-your-nonce'),
requestedCertificates: JSON.parse(response.headers.get('x-bsv-auth-requested-certificates')) as RequestedCertificateSet,
payload: payloadWriter.toArray(),
signature: Utils.toArray(response.headers.get('x-bsv-auth-signature'), 'hex')
}
// If the server didn't provide the correct authentication headers, throw an error
if (responseMessage.version == null) {
throw new Error('HTTP server failed to authenticate')
}
// Handle the response if data is received and callback is set
this.onDataCallback(responseMessage)
}
}
/**
* Registers a callback to handle incoming messages.
* This must be called before sending any messages to ensure responses can be processed.
*
* @param callback - A function to invoke when an incoming AuthMessage is received.
* @returns A promise that resolves once the callback is set.
*/
async onData (callback: (message: AuthMessage) => Promise<void>): Promise<void> {
this.onDataCallback = (m) => {
void callback(m)
}
}
/**
* Deserializes a request payload from a byte array into an HTTP request-like structure.
*
* @param payload - The serialized payload to deserialize.
* @returns An object representing the deserialized request, including the method,
* URL postfix (path and query string), headers, body, and request ID.
*/
deserializeRequestPayload (payload: number[]): {
method: string
urlPostfix: string
headers: Record<string, string>
body: number[]
requestId: string
} {
// Create a reader
const requestReader = new Utils.Reader(payload)
// The first 32 bytes is the requestId
const requestId = Utils.toBase64(requestReader.read(32))
// Method
const methodLength = requestReader.readVarIntNum()
let method = 'GET'
if (methodLength > 0) {
method = Utils.toUTF8(requestReader.read(methodLength))
}
// Path
const pathLength = requestReader.readVarIntNum()
let path = ''
if (pathLength > 0) {
path = Utils.toUTF8(requestReader.read(pathLength))
}
// Search
const searchLength = requestReader.readVarIntNum()
let search = ''
if (searchLength > 0) {
search = Utils.toUTF8(requestReader.read(searchLength))
}
// Read headers
const requestHeaders = {}
const nHeaders = requestReader.readVarIntNum()
if (nHeaders > 0) {
for (let i = 0; i < nHeaders; i++) {
const nHeaderKeyBytes = requestReader.readVarIntNum()
const headerKeyBytes = requestReader.read(nHeaderKeyBytes)
const headerKey = Utils.toUTF8(headerKeyBytes)
const nHeaderValueBytes = requestReader.readVarIntNum()
const headerValueBytes = requestReader.read(nHeaderValueBytes)
const headerValue = Utils.toUTF8(headerValueBytes)
requestHeaders[headerKey] = headerValue
}
}
// Read body
let requestBody
const requestBodyBytes = requestReader.readVarIntNum()
if (requestBodyBytes > 0) {
requestBody = requestReader.read(requestBodyBytes)
}
// Return the deserialized RequestInit
return {
urlPostfix: path + search,
method,
headers: requestHeaders,
body: requestBody,
requestId
}
}
}