@zkportal/aleo-oracle-sdk
Version:
Aleo Oracle SDK
624 lines (616 loc) • 24.5 kB
JavaScript
import dns from 'node:dns';
import net from 'node:net';
import https from 'node:https';
import http from 'node:http';
const DEFAULT_TIMEOUT_MS = 5000;
const DEFAULT_NOTARIZATION_HEADERS = {
Accept: '*/*',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
'Upgrade-Insecure-Requests': '1',
DNT: '1',
};
const DEFAULT_FETCH_OPTIONS = {
cache: 'no-store',
mode: 'cors',
redirect: 'follow',
referrer: '',
keepalive: false,
};
const DEFAULT_NOTARIZATION_OPTIONS = {
dataShouldMatch: true,
timeout: DEFAULT_TIMEOUT_MS,
maxTimeDeviation: undefined,
};
const DEFAULT_NOTARIZATION_BACKENDS = [
{
address: 'sgx.aleooracle.xyz',
port: 443,
https: true,
apiPrefix: '',
resolve: true,
init: DEFAULT_FETCH_OPTIONS,
},
{
address: 'nitro.aleooracle.xyz',
port: 443,
https: true,
apiPrefix: '',
resolve: true,
init: DEFAULT_FETCH_OPTIONS,
},
];
const DEFAULT_VERIFICATION_BACKEND = {
address: 'verifier.aleooracle.xyz',
port: 443,
https: true,
apiPrefix: '',
resolve: true,
init: DEFAULT_FETCH_OPTIONS,
};
class AttestationError extends Error {
/**
* Additional information to help figure out the reason for the error
*/
errorDetails;
/**
* Attestation target's response status code, which exists if the error
* has occurred during or after performing a request to the target.
*/
responseStatusCode;
constructor(err) {
super(err.errorMessage);
this.errorDetails = err.errorDetails;
this.responseStatusCode = err.responseStatusCode;
}
}
class DebugAttestationError extends Error {
/**
* Additional information to help figure out the reason for the error
*/
errorDetails;
/**
* Attestation target's response status code, which exists if the error
* has occurred during or after performing a request to the target.
*/
responseStatusCode;
/**
* Full response body received in the attestation target's response
*/
responseBody;
/**
* Extracted data from `responseBody` using provided selector
*/
extractedData;
constructor(err) {
super(err.errorMessage);
this.errorDetails = err.errorDetails;
this.responseStatusCode = err.responseStatusCode;
this.responseBody = err.responseBody;
this.extractedData = err.extractedData;
}
}
class AttestationIntegrityError extends Error {
}
async function resolve(hostname) {
const { resolve4 } = dns.promises;
return resolve4(hostname);
}
function trimPath(path) {
let res = path.trim();
if (res === '') {
return res;
}
// replace repeating slashes in the beginning with one slash, remove repeating slashes in the end
res = res.replace(/^\/+/, '/').replace(/\/+$/, '');
// if the string didn't have a slash in the beginning in the first place,
// then replace won't work, so we add one manually
if (!res.startsWith('/')) {
res = `/${res}`;
}
return res;
}
// Builds a URL based on the backend config and the desired resource path
function getFullAddress(info, path = '') {
const scheme = info.https ? 'https://' : 'http://';
const trimmedPath = trimPath(path);
return {
host: `${info.address}:${info.port}`,
url: `${scheme}${info.address}:${info.port}${info.apiPrefix}${trimmedPath}`,
path: `${info.apiPrefix}${trimmedPath}`,
};
}
// Builds a URL based on the backend config and the desired resource path. If the address is not an IP, and
// the backend config enables resolving, then this function tries to resolve the address to an IP, then builds
// the full URLs using all of the resolved IPs.
async function getOrResolveFullAddress(info, path) {
let ips = [info.address];
if (net.isIP(info.address) === 0 && info.resolve) {
ips = await resolve(info.address);
}
return ips.map((ip) => {
const trimmedPath = trimPath(path);
const scheme = info.https ? 'https://' : 'http://';
return {
ip,
url: `${scheme}${ip}:${info.port}${info.apiPrefix}${trimmedPath}`,
path: `${info.apiPrefix}${trimmedPath}`,
};
});
}
function trimUrl(url) {
let trimmedUrl = url.trim();
// delete trailing slashes
while (trimmedUrl.endsWith('/')) {
trimmedUrl = trimmedUrl.slice(0, -1);
}
return trimmedUrl;
}
class Response {
headers;
ok;
redirected;
status;
statusText;
type;
url;
#data;
constructor(status, url, headers, data) {
this.headers = headers;
this.ok = status === 200;
this.redirected = false;
this.status = status;
this.statusText = `${status}`;
this.type = 'default';
this.url = url;
this.#data = data;
}
async json() {
return new Promise((resolve, reject) => {
try {
const data = JSON.parse(this.#data);
resolve(data);
}
catch (e) {
reject(e);
}
});
}
async text() {
return Promise.resolve(this.#data);
}
}
async function fetch(backend, ip, path, init) {
let data = '';
const reqObj = {
host: ip,
path,
servername: backend.address,
port: backend.port,
method: init.method,
lookup: backend.resolve ? () => { } : undefined,
headers: {
...init.headers,
host: backend.address,
},
};
return new Promise((resolve, reject) => {
const headersDict = {};
const handleResponse = (res) => {
res.on('data', (d) => {
data += d;
});
res.on('end', () => {
// build headers dict
Object.entries(res.headers).forEach(([header, headerValue]) => {
if (headerValue !== undefined) {
if (Array.isArray(headerValue)) {
headersDict[header] = headerValue.join(', ');
}
else {
headersDict[header] = headerValue;
}
}
});
resolve(new Response(res.statusCode || 0, getFullAddress(backend, path).url, headersDict, data));
});
};
let req;
if (backend.https) {
req = https.request(reqObj, handleResponse);
}
else {
req = http.request(reqObj, handleResponse);
}
req.on('error', (e) => reject(e));
if (init.body) {
req.write(init.body);
}
req.end();
});
}
/* eslint-disable import/prefer-default-export */
// Resolves backends to one or more IPs and builds URLs with the IPs and path string.
async function resolveBackends(backends, path) {
// resolve backends to one or more IPs, then we send the request to all of the resolved IPs
// and get the first response - this helps with availability and geographic load balancing.
return Promise.all(backends.map(async (backend) => {
const resolvedUrls = await getOrResolveFullAddress(backend, path);
return {
backend,
ipAndUrl: resolvedUrls,
};
}));
}
// Performs a request to the backend mesh prepared by resolveBackends
async function requestBackendMesh(backendMesh, method, body, abortSignal) {
const settledResult = await Promise.allSettled(backendMesh.map(async (resolvedBackend) => {
const fetchOptions = {
...resolvedBackend.backend.init,
method,
body: body ? JSON.stringify(body) : undefined,
signal: abortSignal,
headers: {
...resolvedBackend.backend.init.headers,
'Content-Type': 'application/json',
},
};
// each backend may be resolved to more than one IP, send a request to all of them,
// for every backend wait for only one response to arrive.
return Promise.any(resolvedBackend.ipAndUrl.map(({ ip, path }) => fetch(resolvedBackend.backend, ip, path, fetchOptions)));
}));
const responses = [];
const errorReasons = [];
settledResult.forEach((result) => {
if (result.status === 'rejected') {
errorReasons.push(result.reason);
return;
}
responses.push(result.value);
});
if (responses.length === 0) {
throw new Error(`all backends responded with errors: ${JSON.stringify(errorReasons)}`);
}
return responses;
}
async function handleInfoResponse(resp) {
const oracleBackendURL = new URL(resp.url);
let jsonBody;
try {
jsonBody = await resp.json();
}
catch (e) {
// the response doesn't have a JSON body. This is an error response without an error message
throw new Error('failed to parse response', { cause: { error: e, host: oracleBackendURL.host, status: resp.statusText } });
}
if (resp.status !== 200) {
// general error with a JSON body
throw new AttestationError(jsonBody);
}
const result = {
enclaveUrl: oracleBackendURL.origin,
...jsonBody,
};
return result;
}
async function handleAttestationResponse(options, resp) {
const oracleBackendURL = new URL(resp.url);
let jsonBody;
try {
jsonBody = await resp.json();
}
catch (e) {
// the response doesn't have a JSON body. This is an error response without an error message
throw new Error('attestation failed', { cause: { error: e, host: oracleBackendURL.host, status: resp.statusText } });
}
if (resp.status !== 200) {
// this is an error with a JSON body, can be attestation error or debug request error
if (options.debug && jsonBody.responseBody !== undefined) {
// user did a debug request, and we got a debug error response, which is indicated by having a responseBody and errorMessage
throw new DebugAttestationError(jsonBody);
}
else {
// general attestation error with a JSON body
throw new AttestationError(jsonBody);
}
}
// the user requested debugging, the request didn't fail, therefore it's a successful debugging request
if (options.debug) {
return jsonBody;
}
const attestation = {
enclaveUrl: oracleBackendURL.origin,
...jsonBody,
};
return attestation;
}
/**
* @example
* const req: AttestationRequest = {
* url: 'google.com',
* requestMethod: 'GET',
* responseFormat: 'html',
* htmlResultType: 'value',
* selector: '/html/head/title',
* }
*
* const client = new OracleClient();
*
* const resp = await client.notarize(req);
*
* console.log(resp.attestationData); // will print "Google"
*/
class OracleClient {
#oracleBackends;
#verifier;
log;
constructor(config) {
if (config?.quiet ?? false) {
this.log = () => { };
}
else {
this.log = config?.logger || console.log;
}
// we may modify the config, so we create a copy
const conf = { ...config };
// Use the configured notarizer backend, add default fetch options if they are missing.
// Use default notarization backends if the configuration is missing.
// Note that the configuration allows configuring only one backend, while the SDK supports multiple
// notarization backends.
if (conf?.notarizer) {
conf.notarizer.init = {
...DEFAULT_FETCH_OPTIONS,
...conf.notarizer.init,
};
conf.notarizer.apiPrefix = conf.notarizer.apiPrefix || '';
this.#oracleBackends = [conf.notarizer];
}
else {
this.#oracleBackends = DEFAULT_NOTARIZATION_BACKENDS;
}
// sanitize oracle backend configs
this.#oracleBackends = this.#oracleBackends.map((backend) => {
const sanitizedConf = { ...backend };
sanitizedConf.address = trimUrl(backend.address);
sanitizedConf.apiPrefix = trimPath(backend.apiPrefix || '');
return sanitizedConf;
});
this.log('OracleClient: using notarizers:', this.#oracleBackends.map((backend) => getFullAddress(backend).host).join(', '));
// Use the configured verification backend, add default fetch options if they are missing.
// Use default verification backend if the configuration is missing.
if (conf?.verifier) {
conf.verifier.init = {
...DEFAULT_FETCH_OPTIONS,
...conf.verifier.init,
};
conf.verifier.apiPrefix = conf.verifier.apiPrefix || '';
this.#verifier = conf.verifier;
}
else {
this.#verifier = DEFAULT_VERIFICATION_BACKEND;
}
// sanitize verification backend config
this.#verifier.address = trimUrl(this.#verifier.address);
this.#verifier.apiPrefix = trimPath(this.#verifier.apiPrefix || '');
this.log('OracleClient: using verifier:', getFullAddress(this.#verifier).host);
}
/**
* Requests attestation of data extracted from the provided URL using provided selector. Attestation is created by one or more
* Trusted Execution Environments (TEE). If more than one is used (default), all attestation requests should succeed.
*
* Use options to configure attestation.
*
* If `options.dataShouldMatch` is set to `true`, returns extracted data, attested by one of the attesting TEEs.
* Otherwise it returns an array of extracted data, attested by all of the available TEEs.
*
* @throws {AttestationError | AttestationIntegrityError | Error}
*/
async notarize(req, options = DEFAULT_NOTARIZATION_OPTIONS) {
const attestations = await this.createAttestation(req, { timeout: options.timeout, debug: false });
this.log(`OracleClient: attested ${new URL(`https://${req.url}`).host} using ${this.#oracleBackends.length} attesters`);
return this.handleAttestations(attestations, options);
}
/**
* Use this function to test your requests without performing attestation and verification
*
* @throws {DebugAttestationError | Error}
*/
async testSelector(req, timeout) {
return this.createAttestation(req, { timeout, debug: true });
}
/**
* Requests information about enclaves that Notarization Backend is running in
*
* @throws {AttestationError | Error}
*/
async enclavesInfo(options) {
const API_ENDPOINT = '/info';
let abortSignal;
if (options?.timeout && options?.timeout > 0) {
abortSignal = AbortSignal.timeout(options?.timeout);
}
// resolve backends to one or more IPs, then we send the request to all of the resolved IPs
// and get the first response - this helps with availability and geographic load balancing.
const resolvedBackends = await resolveBackends(this.#oracleBackends, API_ENDPOINT);
let responses;
try {
// each backend may be resolved to more than one IP, send a request to all of them,
// for every backend wait for only one response to arrive.
responses = await requestBackendMesh(resolvedBackends, 'GET', undefined, abortSignal);
}
catch (e) {
this.log('OracleClient: all info requests have failed, reason -', e);
throw e;
}
const settledResult = await Promise.allSettled(responses.map((resp) => handleInfoResponse(resp)));
const enclavesInfo = [];
const errors = [];
settledResult.forEach((result) => {
if (result.status === 'rejected') {
errors.push(result.reason);
return;
}
enclavesInfo.push(result.value);
});
if (enclavesInfo.length === 0) {
throw new Error(`all info requests have failed: ${JSON.stringify(errors)}`);
}
return enclavesInfo;
}
/**
* Requests an attested random number within a [0, max) interval.
*
* @throws {AttestationError | AttestationIntegrityError | Error}
*/
async getAttestedRandom(max, options = { ...DEFAULT_NOTARIZATION_OPTIONS, dataShouldMatch: false }) {
// eslint-disable-next-line no-bitwise
if (max <= 1n || max > (2n << 127n)) {
throw new Error('invalid upper bound for random');
}
const API_ENDPOINT = '/random';
let abortSignal;
if (options?.timeout && options?.timeout > 0) {
abortSignal = AbortSignal.timeout(options?.timeout);
}
// resolve backends to one or more IPs, then we send the request to all of the resolved IPs
// and get the first response - this helps with availability and geographic load balancing.
const resolvedBackends = await resolveBackends(this.#oracleBackends, `${API_ENDPOINT}?max=${max.toString(10)}`);
let responses = [];
try {
// each backend may be resolved to more than one IP, send a request to all of them,
// for every backend wait for only one response to arrive.
responses = await requestBackendMesh(resolvedBackends, 'GET', undefined, abortSignal);
}
catch (e) {
this.log('OracleClient: all attestation requests have failed, reason -', e);
throw e;
}
const attestations = await OracleClient.settleAttestationResponses({ timeout: options.timeout, debug: false }, responses);
return this.handleAttestations(attestations, options);
}
/**
* @throws {AttestationIntegrityError | Error}
*/
async verifyReports(attestations, timeout) {
const API_ENDPOINT = '/verify';
const abortSignal = AbortSignal.timeout(timeout);
// resolve backends to one or more IPs, then we send the request to all of the resolved IPs
// and get the first response - this helps with availability and geographic load balancing.
const resolvedUrls = await getOrResolveFullAddress(this.#verifier, API_ENDPOINT);
const fetchOptions = {
...this.#verifier.init,
signal: abortSignal,
method: 'POST',
headers: {
...this.#verifier.init.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({ reports: attestations }),
};
let response;
try {
response = await Promise.any(resolvedUrls.map(({ ip, path }) => fetch(this.#verifier, ip, path, fetchOptions)));
}
catch (e) {
this.log('OracleClient: verification request have failed, reason -', e);
throw e;
}
if (response.status !== 200) {
throw new AttestationIntegrityError(`verification failed: ${response.statusText}`);
}
let jsonBody;
try {
jsonBody = await response.json();
}
catch (e) {
this.log(`OracleClient: failed to parse verification response from ${this.#verifier}, reason - ${e}`);
throw new Error('verification failed', { cause: { host: this.#verifier, status: response.statusText } });
}
if (!jsonBody.validReports || jsonBody.validReports.length === 0) {
throw new AttestationIntegrityError(`verification failed for all reports: ${jsonBody.errorMessage}`);
}
const validAttestations = attestations.filter((_, index) => jsonBody.validReports.includes(index));
return validAttestations;
}
/**
* @throws {AttestationError | DebugAttestationError | Error}
*/
async createAttestation(req, options = { timeout: DEFAULT_TIMEOUT_MS, debug: false }) {
const API_ENDPOINT = '/notarize';
// construct oracle HTTP request body
const attestReq = {
...req,
requestHeaders: {
...DEFAULT_NOTARIZATION_HEADERS,
...(req.requestHeaders ?? {}),
},
debugRequest: options.debug,
};
let abortSignal;
if (options?.timeout && options?.timeout > 0) {
abortSignal = AbortSignal.timeout(options?.timeout);
}
const resolvedBackends = await resolveBackends(this.#oracleBackends, API_ENDPOINT);
let responses = [];
try {
responses = await requestBackendMesh(resolvedBackends, 'POST', attestReq, abortSignal);
}
catch (e) {
this.log('OracleClient: all attestation requests have failed, reason -', e);
throw e;
}
const attestations = await OracleClient.settleAttestationResponses(options, responses);
return attestations;
}
async handleAttestations(attestations, options) {
const numAttestations = attestations.length;
if (numAttestations === 0 || numAttestations > this.#oracleBackends.length) {
throw new AttestationIntegrityError('unexpected number of attestations', { cause: `expected ${this.#oracleBackends.length}, got ${numAttestations}` });
}
// do some basic client side validation
const firstAttestation = attestations[0];
const attestationTimestamps = [attestations[0].timestamp];
for (let i = 1; i < numAttestations; i++) {
// data matching is disabled, this check is done first for a possibility of early exit
if (options.dataShouldMatch && attestations[i].attestationData !== firstAttestation.attestationData) {
throw new AttestationIntegrityError('attestation data mismatch', { cause: attestations.map((at) => at.attestationData) });
}
// save the timestamps to check for deviation of all attestations
attestationTimestamps.push(attestations[i].timestamp);
}
if (options.maxTimeDeviation !== undefined) {
// warn the user that it's not recommended to have a deviation less than 10ms or more than 10s
if (options.maxTimeDeviation < 10 || options.maxTimeDeviation > 10 * 1000) {
this.log(`OracleClient: WARNING max time deviation for attestation of ${options.maxTimeDeviation}ms is not recommended`);
}
// test that all attestations were done within the allowed deviation
attestationTimestamps.sort();
// the difference between the soonest and latest timestamps shouldn't be more than the configured deviation
if (attestationTimestamps[numAttestations - 1] - attestationTimestamps[0] > options.maxTimeDeviation) {
throw new AttestationIntegrityError('attestation timestamps deviate too much', { cause: { maxTimeDeviation: options.maxTimeDeviation, attestationTimestamps } });
}
}
const validAttestations = await this.verifyReports(attestations, options.timeout || DEFAULT_TIMEOUT_MS);
if (validAttestations.length === 0) {
throw new AttestationIntegrityError('failed to verify reports');
}
return validAttestations;
}
static async settleAttestationResponses(options, responses) {
const settledResult = await Promise.allSettled(responses.map(async (response) => handleAttestationResponse(options, response)));
const attestations = [];
const errors = [];
settledResult.forEach((result) => {
if (result.status === 'rejected') {
errors.push(result.reason);
return;
}
attestations.push(result.value);
});
if (attestations.length === 0) {
throw new Error(`all attestations failed: ${JSON.stringify(errors)}`);
}
return attestations;
}
}
export { AttestationError, AttestationIntegrityError, DEFAULT_FETCH_OPTIONS, DEFAULT_NOTARIZATION_BACKENDS, DEFAULT_NOTARIZATION_HEADERS, DEFAULT_NOTARIZATION_OPTIONS, DEFAULT_TIMEOUT_MS, DEFAULT_VERIFICATION_BACKEND, DebugAttestationError, OracleClient };
//# sourceMappingURL=index.js.map