import b4a from 'b4a'
import {
FAMILY,
FAMILY_ROUTES,
OPERATION,
OUTER_CLASS,
PROTOCOL
} from '@hiverelay/blind-protocol/wire-runtime-authority'
import { asBytes } from './bytes.js'
import { BlindClientError, fail } from './errors.js'
import { createDescribeGetRequest, verifyDescriptorBytes } from './describe.js'
import { decodeUnaryResponse, encodeUnaryRequest } from './wire.js'
const MAX_TIMEOUT_MILLIS = 15_000
function bootstrapUrl (value, allowInsecureLoopback) {
const text = b4a.toString(asBytes(value, 'bootstrap canonicalUrl'), 'utf8')
let url
try {
url = new URL(text)
} catch (error) {
fail('BAD_CLIENT_INPUT', 'bootstrap canonicalUrl is invalid', { cause: error })
}
if (url.pathname !== FAMILY_ROUTES[FAMILY.DESCRIBE] || url.search || url.hash ||
url.username || url.password) {
fail('TRANSPORT_MISMATCH', 'bootstrap URL is not the fixed DESCRIBE listener anchor')
}
const loopback = url.hostname === '127.0.0.1' || url.hostname === '::1' || url.hostname === '[::1]'
if (url.protocol !== 'https:' && !(allowInsecureLoopback && loopback && url.protocol === 'http:')) {
fail('TRANSPORT_MISMATCH', 'descriptor bootstrap must use HTTPS')
}
return url.href
}
function timeoutMillis (value) {
if (value == null) return MAX_TIMEOUT_MILLIS
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_TIMEOUT_MILLIS) {
fail('BAD_CLIENT_INPUT', `timeoutMillis must be within 1..${MAX_TIMEOUT_MILLIS}`)
}
return value
}
function abortScope (parent, timeout) {
const controller = new AbortController()
const abort = () => controller.abort(parent && parent.reason)
if (parent) {
if (parent.aborted) abort()
else parent.addEventListener('abort', abort, { once: true })
}
const timer = setTimeout(() => controller.abort(new Error('descriptor bootstrap deadline elapsed')), timeout)
return {
signal: controller.signal,
close () {
clearTimeout(timer)
if (parent) parent.removeEventListener('abort', abort)
}
}
}
async function readExact (response, exactBytes, signal) {
const declared = response.headers && response.headers.get ? response.headers.get('content-length') : null
const contentEncoding = response.headers && response.headers.get ? response.headers.get('content-encoding') : null
const transferEncoding = response.headers && response.headers.get ? response.headers.get('transfer-encoding') : null
if (contentEncoding != null || transferEncoding != null) {
fail('RELAY_PROTOCOL_VIOLATION', 'encoded or transfer-framed bootstrap responses are forbidden')
}
if (declared == null || !/^(0|[1-9][0-9]*)$/.test(declared) || Number(declared) !== exactBytes) {
fail('RELAY_PROTOCOL_VIOLATION', 'bootstrap response must declare the exact selected class')
}
if (!response.body || typeof response.body.getReader !== 'function') {
fail('TRANSPORT_UNAVAILABLE', 'a bounded streaming bootstrap response is required')
}
const reader = response.body.getReader()
const output = b4a.alloc(exactBytes)
let total = 0
const onAbort = () => Promise.resolve(reader.cancel(signal.reason)).catch(() => {})
if (signal.aborted) onAbort()
else signal.addEventListener('abort', onAbort, { once: true })
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
const chunk = asBytes(value, 'bootstrap response chunk')
total += chunk.byteLength
if (total > exactBytes) fail('RELAY_PROTOCOL_VIOLATION', 'bootstrap response exceeds the selected class')
b4a.copy(chunk, output, total - chunk.byteLength)
}
} finally {
signal.removeEventListener('abort', onAbort)
if (total > exactBytes && typeof reader.cancel === 'function') await reader.cancel().catch(() => {})
if (typeof reader.releaseLock === 'function') reader.releaseLock()
}
if (total !== exactBytes) fail('RELAY_PROTOCOL_VIOLATION', 'bootstrap response is shorter than the selected class')
return output
}
// This is deliberately not a generic raw-endpoint transport. It can perform
// only a hash-named DESCRIBE.GET and returns only after the descriptor signature,
// exact expected hash and supported profile pins have all verified.
export class BlindDescriptorBootstrapHttpClient {
constructor (options = {}) {
this.runtime = options.runtime
this.fetch = options.fetch || globalThis.fetch
if (typeof this.fetch !== 'function') fail('TRANSPORT_UNAVAILABLE', 'fetch implementation is required')
this.allowInsecureLoopback = options.allowInsecureLoopback === true
}
async fetchVerifiedDescriptor (options) {
if (!options || typeof options !== 'object') fail('BAD_CLIENT_INPUT', 'descriptor bootstrap options are required')
const expectedDescriptorHash = b4a.from(asBytes(
options.expectedDescriptorHash, 'expectedDescriptorHash', 32))
const describe = createDescribeGetRequest({
runtime: this.runtime,
descriptorHash: expectedDescriptorHash,
clientNonce: options.clientNonce
})
const encoded = encodeUnaryRequest({
runtime: this.runtime,
requestId: options.requestId,
familyId: FAMILY.DESCRIBE,
operationId: OPERATION.DESCRIBE.GET,
expectedResultBodyBytes: describe.wire.expectedResultBodyBytes,
body: describe.requestBytes
})
const scope = abortScope(options.signal, timeoutMillis(options.timeoutMillis))
try {
const response = await this.fetch(bootstrapUrl(options.canonicalUrl, this.allowInsecureLoopback), {
method: 'POST',
headers: [['content-type', PROTOCOL.mediaType]],
body: encoded.body,
credentials: 'omit',
cache: 'no-store',
redirect: 'error',
referrerPolicy: 'no-referrer',
signal: scope.signal
})
if (!response || response.status !== 200) {
fail('TRANSPORT_FAILURE', 'descriptor bootstrap returned a non-protocol status')
}
const contentType = response.headers && response.headers.get ? response.headers.get('content-type') : null
if (contentType !== PROTOCOL.mediaType) {
fail('RELAY_PROTOCOL_VIOLATION', 'bootstrap response media type is not the blind protocol')
}
const bytes = await readExact(response, OUTER_CLASS[encoded.outerClass], scope.signal)
const result = decodeUnaryResponse(bytes, encoded)
if (!result.ok) fail('TRANSPORT_FAILURE', 'descriptor bootstrap returned a canonical relay error')
return verifyDescriptorBytes(result.body, {
expectedDescriptorHash,
nowEpoch: options.nowEpoch,
history: options.history === true,
supportedProtocolProfiles: options.supportedProtocolProfiles,
supportedTransportProfiles: options.supportedTransportProfiles
})
} catch (error) {
if (error instanceof BlindClientError) throw error
fail('TRANSPORT_FAILURE', 'descriptor bootstrap failed', { cause: error })
} finally {
scope.close()
}
}
}