libkernel/src/kernel/queries.ts

543 lines
20 KiB
TypeScript

import { log, logErr } from "./log.js";
import { DataFn, Err, ErrTuple, bufToB64, encodeU64 } from "@lumeweb/libweb";
// queryResolve is the 'resolve' value of a promise that returns an ErrTuple.
// It gets called when a query sends a 'response' message.
type queryResolve = (er: ErrTuple) => void;
// queryMap is a hashmap that maps a nonce to an open query. 'resolve' gets
// called when a response has been provided for the query.
//
// 'receiveUpdate' is a function that gets called every time a responseUpdate
// message is sent to the query. If a responseUpdate is sent but there is no
// 'receiveUpdate' method defined, the update will be ignored.
//
// 'kernelNonceReceived' is a promise that resolves when the kernel nonce has
// been received from the kernel, which is a prerequesite for sending
// queryUpdate messages. The promise will resolve with a string that contains
// the kernel nonce.
interface queryMap {
[nonce: string]: {
resolve: queryResolve;
receiveUpdate?: DataFn;
kernelNonceReceived?: DataFn;
};
}
// Create the queryMap.
const queries: queryMap = {};
// Define the nonce handling. nonceSeed is 16 random bytes that get generated
// at init and serve as the baseline for creating random nonces. nonceCounter
// tracks which messages have been sent. We hash together the nonceSeed and the
// current nonceCounter to get a secure nonce.
//
// We need a secure nonce so that we know which messages from the kernel are
// intended for us. There could be multiple pieces of independent code talking
// to the kernel and using nonces, by having secure random nonces we can
// guarantee that the applications will not use conflicting nonces.
let nonceSeed: Uint8Array;
let nonceCounter: number;
function initNonce() {
nonceSeed = new Uint8Array(16);
nonceCounter = 0;
crypto.getRandomValues(nonceSeed);
}
// nextNonce will combine the nonceCounter with the nonceSeed to produce a
// unique string that can be used as the nonce with the kernel.
//
// Note: the nonce is only ever going to be visible to the kernel and to other
// code running in the same webpage, so we don't need to hash our nonceSeed. We
// just need it to be unique, not undetectable.
function nextNonce(): string {
const nonceNum = nonceCounter;
nonceCounter += 1;
const [nonceNumBytes, err] = encodeU64(BigInt(nonceNum));
if (err !== null) {
// encodeU64 only fails if nonceNum is outside the bounds of a
// uint64, which shouldn't happen ever.
logErr("encodeU64 somehow failed", err);
}
const noncePreimage = new Uint8Array(nonceNumBytes.length + nonceSeed.length);
noncePreimage.set(nonceNumBytes, 0);
noncePreimage.set(nonceSeed, nonceNumBytes.length);
return bufToB64(noncePreimage);
}
// Establish the handler for incoming messages.
function handleMessage(event: MessageEvent) {
// Ignore all messages that aren't from approved kernel sources. The two
// approved sources are skt.us and the browser extension bridge (which has
// an event.source equal to 'window')
if (event.source !== window && event.origin !== "https://skt.us") {
return;
}
// Ignore any messages that don't have a method and data field.
if (!("method" in event.data) || !("data" in event.data)) {
return;
}
// Handle logging messages.
if (event.data.method === "log") {
// We display the logging message if the kernel is a browser
// extension, so that the kernel's logs appear in the app
// console as well as the extension console. If the kernel is
// in an iframe, its logging messages will already be in the
// app console and therefore don't need to be displayed.
if (kernelOrigin === window.origin) {
if (event.data.data.isErr) {
console.error(event.data.data.message);
} else {
console.log(event.data.data.message);
}
}
return;
}
// init is complete when the kernel sends us the auth status. If the
// user is logged in, report success, otherwise return an error
// indicating that the user is not logged in.
if (event.data.method === "kernelAuthStatus") {
// If we have received an auth status message, it means the bootloader
// at a minimum is working.
if (initResolved === false) {
initResolved = true;
// We can't actually establish that init is complete until the
// kernel source has been set. This happens async and might happen
// after we receive the auth message.
sourcePromise.then(() => {
initResolve();
});
}
// If the auth status message says that login is complete, it means
// that the user is logged in.
if (loginResolved === false && event.data.data.loginComplete === true) {
loginResolved = true;
loginResolve();
}
// If the auth status message says that the kernel loaded, it means
// that the kernel is ready to receive messages.
if (
kernelLoadedResolved === false &&
event.data.data.kernelLoaded !== "not yet"
) {
kernelLoadedResolved = true;
if (event.data.data.kernelLoaded === "success") {
kernelLoadedResolve(null);
} else {
kernelLoadedResolve(event.data.data.kernelLoaded);
}
}
// If we have received a message indicating that the user has logged
// out, we need to reload the page and reset the auth process.
if (event.data.data.logoutComplete === true) {
if (logoutResolved === false) {
logoutResolve();
}
window.location.reload();
}
return;
}
// Check that the message sent has a nonce. We don't log
// on failure because the message may have come from 'window', which
// will happen if the app has other messages being sent to the window.
if (!("nonce" in event.data)) {
return;
}
// If we can't locate the nonce in the queries map, there is nothing to do.
// This can happen especially for responseUpdate messages.
if (!(event.data.nonce in queries)) {
return;
}
const query = queries[event.data.nonce];
// Handle a response. Once the response has been received, it is safe to
// delete the query from the queries map.
if (event.data.method === "response") {
queries[event.data.nonce].resolve([event.data.data, event.data.err]);
delete queries[event.data.nonce];
return;
}
// Handle a response update.
if (event.data.method === "responseUpdate") {
// If no update handler was provided, there is nothing to do.
if (typeof query.receiveUpdate === "function") {
query.receiveUpdate(event.data.data);
}
return;
}
// Handle a responseNonce.
if (event.data.method === "responseNonce") {
if (typeof query.kernelNonceReceived === "function") {
query.kernelNonceReceived(event.data.data.nonce);
}
return;
}
// Ignore any other messages as they might be from other applications.
}
// messageBridge will send a message to the bridge of the lume extension to
// see if it exists. If it does not respond or if it responds with an error,
// messageBridge will open an iframe to skt.us and use that as the kernel.
let kernelSource: Window;
let kernelOrigin: string;
let kernelAuthLocation: string;
function messageBridge() {
// Establish the function that will handle the bridge's response.
let bridgeInitComplete = false;
let bridgeResolve: queryResolve = () => {}; // Need to set bridgeResolve here to make tsc happy
const p: Promise<ErrTuple> = new Promise((resolve) => {
bridgeResolve = resolve;
});
p.then(([, err]) => {
// Check if the timeout already elapsed.
if (bridgeInitComplete === true) {
logErr("received response from bridge, but init already finished");
return;
}
bridgeInitComplete = true;
// Bridge has responded successfully, and there's no error.
kernelSource = window;
kernelOrigin = window.origin;
kernelAuthLocation = "http://kernel.lume/auth.html";
console.log(
"established connection to bridge, using browser extension for kernel",
);
sourceResolve();
});
// Add the handler to the queries map.
const nonce = nextNonce();
queries[nonce] = {
resolve: bridgeResolve,
};
// Send a message to the bridge of the browser extension to determine
// whether the bridge exists.
window.postMessage(
{
nonce,
method: "kernelBridgeVersion",
},
window.origin,
);
// Set a timeout, if we do not hear back from the bridge in 500
// milliseconds we assume that the bridge is not available.
setTimeout(() => {
// If we've already received and processed a message from the
// bridge, there is nothing to do.
if (bridgeInitComplete === true) {
return;
}
bridgeInitComplete = true;
}, 500);
return initPromise;
}
// init is a function that returns a promise which will resolve when
// initialization is complete.
//
// The init / auth process has 5 stages. The first stage is that something
// somewhere needs to call init(). It is safe to call init() multiple times,
// thanks to the 'initialized' variable.
let initialized = false; // set to true once 'init()' has been called
let initResolved = false; // set to true once we know the bootloader is working
let initResolve: DataFn;
let initPromise: Promise<void>;
let loginResolved = false; // set to true once we know the user is logged in
let loginResolve: () => void;
let loginPromise: Promise<void>;
let kernelLoadedResolved = false; // set to true once the user kernel is loaded
let kernelLoadedResolve: (err: Err) => void;
let kernelLoadedPromise: Promise<Err>;
const logoutResolved = false; // set to true once the user is logged out
let logoutResolve: () => void;
let logoutPromise: Promise<void>;
let sourceResolve: () => void;
let sourcePromise: Promise<void>; // resolves when the source is known and set
function init(): Promise<void> {
// If init has already been called, just return the init promise.
if (initialized === true) {
return initPromise;
}
initialized = true;
// Run all of the init functions.
initNonce();
window.addEventListener("message", handleMessage);
messageBridge();
// Create the promises that resolve at various stages of the auth flow.
initPromise = new Promise((resolve) => {
initResolve = resolve;
});
loginPromise = new Promise((resolve) => {
loginResolve = resolve;
});
kernelLoadedPromise = new Promise((resolve) => {
kernelLoadedResolve = resolve;
});
logoutPromise = new Promise((resolve) => {
logoutResolve = resolve;
});
sourcePromise = new Promise((resolve) => {
sourceResolve = resolve;
});
// Return the initPromise, which will resolve when bootloader init is
// complete.
return initPromise;
}
// callModule is a generic function to call a module. The first input is the
// module identifier (typically a skylink), the second input is the method
// being called on the module, and the final input is optional and contains
// input data to be passed to the module. The input data will depend on the
// module and the method that is being called. The return value is an ErrTuple
// that contains the module's response. The format of the response is an
// arbitrary object whose fields depend on the module and method being called.
//
// callModule can only be used for query-response communication, there is no
// support for sending or receiving updates.
function callModule(
module: string,
method: string,
data?: any,
): Promise<ErrTuple> {
const moduleCallData = {
module,
method,
data,
};
const [, query] = newKernelQuery("moduleCall", moduleCallData, false);
return query;
}
// connectModule is the standard function to send a query to a module that can
// optionally send and optionally receive updates. The first three inputs match
// the inputs of 'callModule', and the fourth input is a function that will be
// called any time that the module sends a responseUpdate. The receiveUpdate
// function should have the following signature:
//
// `function receiveUpdate(data: any)`
//
// The structure of the data will depend on the module and method that was
// queried.
//
// The first return value is a 'sendUpdate' function that can be called to send
// a queryUpdate to the module. The sendUpdate function has the same signature
// as the receiveUpdate function, it's an arbitrary object whose fields depend
// on the module and method being queried.
//
// The second return value is a promise that returns an ErrTuple. It will
// resolve when the module sends a response message, and works the same as the
// return value of callModule.
function connectModule(
module: string,
method: string,
data: any,
receiveUpdate: DataFn,
): [sendUpdate: DataFn, response: Promise<ErrTuple>] {
const moduleCallData = {
module,
method,
data,
};
return newKernelQuery("moduleCall", moduleCallData, true, receiveUpdate);
}
// newKernelQuery opens a query to the kernel. Details like postMessage
// communication and nonce handling are all abstracted away by newKernelQuery.
//
// The first arg is the method that is being called on the kernel, and the
// second arg is the data that will be sent to the kernel as input to the
// method.
//
// The thrid arg is an optional function that can be passed in to receive
// responseUpdates to the query. Not every query will send responseUpdates, and
// most responseUpdates can be ignored, but sometimes contain useful
// information like download progress.
//
// The first output is a 'sendUpdate' function that can be called to send a
// queryUpdate. The second output is a promise that will resolve when the query
// receives a response message. Once the response message has been received, no
// more updates can be sent or received.
function newKernelQuery(
method: string,
data: any,
sendUpdates: boolean,
receiveUpdate?: DataFn,
): [sendUpdate: DataFn, response: Promise<ErrTuple>] {
// NOTE: The implementation here is gnarly, because I didn't want to use
// async/await (that decision should be left to the caller) and I also
// wanted this function to work correctly even if init() had not been
// called yet.
//
// This function returns a sendUpdate function along with a promise, so we
// can't simply wrap everything in a basic promise. The sendUpdate function
// has to block internally until all of the setup is complete, and then we
// can't send a query until all of the setup is complete, and the setup
// cylce has multiple dependencies and therefore we get a few promises that
// all depend on each other.
//
// Using async/await here actually breaks certain usage patterns (or at
// least makes them much more difficult to use correctly). The standard way
// to establish duplex communication using connectModule is to define a
// variable 'sendUpdate' before defining the function 'receiveUpdate', and
// then setting 'sendUpdate' equal to the first return value of
// 'connectModue'. It looks like this:
//
// let sendUpdate;
// let receiveUpdate = function(data: any) {
// if (data.needsUpdate) {
// sendUpdate(someUpdate)
// }
// }
// let [sendUpdateFn, response] = connectModule(x, y, z, receiveUpdate)
// sendUpdate = sendUpdateFn
//
// If we use async/await, it's not safe to set sendUpdate after
// connectModule returns because 'receiveUpdate' may be called before
// 'sendUpdate' is set. You can fix that by using a promise, but it's a
// complicated fix and we want this library to be usable by less
// experienced developers.
//
// Therefore, we make an implementation tradeoff here and avoid async/await
// at the cost of having a bunch of complicated promise chaining.
// Create a promise that will resolve once the nonce is available. We
// cannot get the nonce until init() is complete. getNonce therefore
// implies that init is complete.
const getNonce: Promise<string> = new Promise((resolve) => {
init().then(() => {
kernelLoadedPromise.then(() => {
resolve(nextNonce());
});
});
});
// Two promises are being created at once here. Once is 'p', which will be
// returned to the caller of newKernelQuery and will be resolved when the
// kernel provides a 'response' message. The other is for internal use and
// will resolve once the query has been created.
let p!: Promise<ErrTuple>;
const haveQueryCreated: Promise<string> = new Promise(
(queryCreatedResolve) => {
p = new Promise((resolve) => {
getNonce.then((nonce: string) => {
queries[nonce] = { resolve };
if (receiveUpdate !== null && receiveUpdate !== undefined) {
queries[nonce]["receiveUpdate"] = receiveUpdate;
}
queryCreatedResolve(nonce);
});
});
},
);
// Create a promise that will be resolved once we are ready to receive the
// kernelNonce. We won't be ready to receive the kernel nonce until after
// the queries[nonce] object has been created.
let readyForKernelNonce!: DataFn;
const getReadyForKernelNonce: Promise<void> = new Promise((resolve) => {
readyForKernelNonce = resolve;
});
// Create the sendUpdate function. It defaults to doing nothing. After the
// sendUpdate function is ready to receive the kernelNonce, resolve the
// promise that blocks until the sendUpdate function is ready to receive
// the kernel nonce.
let sendUpdate: DataFn;
if (sendUpdates !== true) {
sendUpdate = () => {};
readyForKernelNonce(); // We won't get a kernel nonce, no reason to block.
} else {
// sendUpdate will send an update to the kernel. The update can't be
// sent until the kernel nonce is known. Create a promise that will
// resolve when the kernel nonce is known.
//
// This promise cannot itself be created until the queries[nonce]
// object has been created, so block for the query to be created.
const blockForKernelNonce: Promise<string> = new Promise((resolve) => {
haveQueryCreated.then((nonce: string) => {
queries[nonce]["kernelNonceReceived"] = resolve;
readyForKernelNonce();
});
});
// The sendUpdate function needs both the local nonce and also the
// kernel nonce. Block for both. Having the kernel nonce implies that
// the local nonce is ready, therefore start by blocking for the kernel
// nonce.
sendUpdate = function (updateData: any) {
blockForKernelNonce.then((nonce: string) => {
kernelSource.postMessage(
{
method: "queryUpdate",
nonce,
data: updateData,
},
kernelOrigin,
);
});
};
}
// Prepare to send the query to the kernel. The query cannot be sent until
// the queries object is created and also we are ready to receive the
// kernel nonce.
haveQueryCreated.then((nonce: string) => {
getReadyForKernelNonce.then(() => {
// There are two types of messages we can send depending on whether
// we are talking to skt.us or the background script.
const kernelMessage = {
method,
nonce,
data,
sendKernelNonce: sendUpdates,
};
const backgroundMessage = {
method: "newKernelQuery",
nonce,
data: kernelMessage,
};
// The message structure needs to adjust based on whether we are
// talking directly to the kernel or whether we are talking to the
// background page.
if (kernelOrigin === "https://skt.us") {
kernelSource.postMessage(kernelMessage, kernelOrigin);
} else {
kernelSource.postMessage(backgroundMessage, kernelOrigin);
}
});
});
// Return sendUpdate and the promise. sendUpdate is already set to block
// until all the necessary prereqs are complete.
return [sendUpdate, p];
}
export {
callModule,
connectModule,
init,
kernelAuthLocation,
kernelLoadedPromise,
loginPromise,
logoutPromise,
newKernelQuery,
};