UNPKG

@x5e/gink

Version:

an eventually consistent database

848 lines (775 loc) 27.2 kB
/** * Herein lay a bunch of utility functions, mostly for creating and * manipulating the types defined in typedefs.ts. */ import { Muid, Medallion, Value, MuidTuple, ScalarKey, EdgeData, Entry, ActorId, Timestamp, Bytes, KeyPair, StorageKey, } from "./typedefs"; import { MuidBuilder, ValueBuilder, KeyBuilder, Special, TupleBuilder, DocumentBuilder, } from "./builders"; import { TreeMap, MapIterator } from "jstreemap"; const nodeOs = typeof window === "undefined" ? eval("require('os')") : undefined; const hostname = nodeOs?.hostname || (() => "browser"); const userInfo = nodeOs?.userInfo || (() => ({ username: "browser-user" })); import { ready as sodium_ready, crypto_sign_open, crypto_sign_keypair, crypto_sign, crypto_generichash_BYTES, crypto_generichash, crypto_shorthash, crypto_shorthash_KEYBYTES, randombytes_buf, crypto_secretbox_easy, crypto_secretbox_NONCEBYTES, crypto_secretbox_MACBYTES, crypto_secretbox_open_easy, } from "libsodium-wrappers"; export const emptyBytes = new Uint8Array(0); const TIMESTAMP_HEX_DIGITS = 13; const MEDALLION_HEX_DIGITS = 11; const OFFSET_HEX_DIGITS = 8; const MAXIMUM_MEDALLION = 16 ** MEDALLION_HEX_DIGITS - 1; let shorthashKey: Uint8Array = emptyBytes; export function getShortHashKey(): Bytes { if (shorthashKey.length === 0) shorthashKey = new Uint8Array( Array(crypto_shorthash_KEYBYTES).fill(0x5e), ); return shorthashKey; } export const safeMask = BigInt(2 ** 52 - 1); export function shorterHash(data: Bytes): number { // I'm using this truncated shorthash because the Google protobuf library can't handle bignums. const out1 = crypto_shorthash(data, getShortHashKey()); const asBigNum = new DataView(out1.buffer).getBigUint64(0, true); return Number(asBigNum & safeMask); } export const digest = (data: Bytes) => crypto_generichash(crypto_generichash_BYTES, data); export const librariesReady = sodium_ready; export const signingBundles = true; export function noOp(..._args: any[]) {} export function toLastWithPrefixBeforeSuffix<V>( map: TreeMap<string, V>, prefix: string, suffix: string = "~", ): MapIterator<string, V> | undefined { const iterator = map.upperBound(prefix + suffix); iterator.prev(); if (!iterator.key) return undefined; if (!iterator.key.startsWith(prefix)) return undefined; return iterator; } export function dumpTree<V>(map: TreeMap<string, V>) { let it = map.begin(); while (it.key) { console.log(JSON.stringify(it.value)); it.next(); } } // Since find-process uses child-process, we can't load this if gink // is running in a browser // TODO: only install this package when you will be using as a backend? const findProcess = typeof window === "undefined" ? eval("require('find-process')") : undefined; export const inspectSymbol = Symbol.for("nodejs.util.inspect.custom"); export function ensure(x: any, msg?: string) { if (!x) { throw new Error(msg ?? "assert failed"); } return x; } let lastTime = 0; export function generateTimestamp() { // TODO: there's probably a better way ... let current = Date.now() * 1000; if (lastTime >= current) { current = lastTime + 20; } lastTime = current; return current; } /** * Converts a storage key (which is the key used in EntryBuilders) to a * key usable by addEntry, etc. * @param storageKey * @returns */ export function fromStorageKey( storageKey: StorageKey, ): ScalarKey | Muid | [Muid, Muid] { let newKey: ScalarKey | Muid | [Muid, Muid]; if (Array.isArray(storageKey)) { if (storageKey.length === 3) { newKey = muidTupleToMuid(storageKey); } else if (storageKey.length === 2) { newKey = [ muidTupleToMuid(storageKey[0]), muidTupleToMuid(storageKey[1]), ]; } else { throw new Error("Invalid key length?"); } } else { newKey = storageKey; } return newKey; } const MIN_RANDOM_MEDALLION = 16 ** (MEDALLION_HEX_DIGITS - 1); const MAX_RANDOM_MEDALLION = MIN_RANDOM_MEDALLION * 2 - 1; var nodeCrypto = typeof window === "undefined" ? eval("require('crypto')") : undefined; /** * Randomly selects a number that can be used as a medallion. * Note that this doesn't actually have to be cryptographically secure; * as long as it's unique within an organization there won't be problems. * This is unlikely to cause collisions as long as an organization * has fewer than a million instances, after that some tracking is warranted. * https://en.wikipedia.org/wiki/Birthday_problem#Probability_table */ export function generateMedallion() { const cryptoLib = nodeCrypto || window.crypto; if (cryptoLib) { if (cryptoLib.getRandomValues) { const array = new Uint8Array(MEDALLION_HEX_DIGITS - 1); cryptoLib.getRandomValues(array); let total = 1; for (let i = 0; i < array.length; i++) { const inc = array[i] & 15; ensure( inc >= 0 && total > 0, `problem, inc=${inc}, total=${total}, i=${i}`, ); total = total * 16; total = total + inc; } ensure( total >= MIN_RANDOM_MEDALLION && total <= MAX_RANDOM_MEDALLION, `generated medallion not in expected range ${total} ${array[0]} ${array[1]}`, ); return total; } if (cryptoLib.randomInt) { return cryptoLib.randomInt( MIN_RANDOM_MEDALLION, MAX_RANDOM_MEDALLION, ); } } var basic = Math.floor(Math.random() * MIN_RANDOM_MEDALLION) + MIN_RANDOM_MEDALLION; ensure(basic >= MIN_RANDOM_MEDALLION && basic <= MAX_RANDOM_MEDALLION); return basic; } export function muidToBuilder( address: Muid, relativeTo?: Medallion, ): MuidBuilder { const muid = new MuidBuilder(); if (address.medallion && address.medallion !== relativeTo) muid.setMedallion(address.medallion); if (address.timestamp) // not set if also pending muid.setTimestamp(address.timestamp); muid.setOffset(address.offset); return muid; } export function builderToMuid( muidBuilder: MuidBuilder, relativeTo?: Muid, ): Muid { // If a MuidBuilder in a message has a zero medallion and/or timestamp, it should be // interpreted that those values are the same as the trxn it comes from. return { timestamp: muidBuilder.getTimestamp() || relativeTo.timestamp, medallion: muidBuilder.getMedallion() || relativeTo.medallion, offset: ensure(muidBuilder.getOffset(), "zero offset"), }; } /** * Converts from a KeyType (number or string) to a Gink Proto * @param key * @returns */ export function wrapKey(key: number | string | Uint8Array): KeyBuilder { const keyBuilder = new KeyBuilder(); if (typeof key === "string") { keyBuilder.setCharacters(key); return keyBuilder; } if (typeof key === "number") { ensure(Number.isSafeInteger(key), `key=${key} not a safe integer`); keyBuilder.setNumber(key); return keyBuilder; } if (key instanceof Uint8Array) { keyBuilder.setOctets(key); return keyBuilder; } throw new Error(`key not a number or string or bytes: ${key}`); } /** * Convert from a Gink Proto known to contain a string or number * into the equiv Javascript object. * @param keyBuilder * @returns */ export function unwrapKey(keyBuilder: KeyBuilder): ScalarKey { ensure(keyBuilder); if (keyBuilder.hasCharacters()) { return keyBuilder.getCharacters(); } if (keyBuilder.hasNumber()) { return keyBuilder.getNumber(); } if (keyBuilder.hasOctets()) { return new Uint8Array(keyBuilder.getOctets_asU8()); } throw new Error("value isn't a number or string!"); } /** * Convert from a Gink Proto (Builder) for a Value to the corresponding JS object. * @param valueBuilder Gink Proto for Value * @returns */ export function unwrapValue(valueBuilder: ValueBuilder): Value { ensure(valueBuilder instanceof ValueBuilder); if (valueBuilder.hasCharacters()) { return valueBuilder.getCharacters(); } if (valueBuilder.hasFloating()) { return valueBuilder.getFloating(); } if (valueBuilder.hasInteger()) { return BigInt(valueBuilder.getInteger()); } if (valueBuilder.hasSpecial()) { const special = valueBuilder.getSpecial(); if (special === Special.NULL) return null; if (special === Special.TRUE) return true; if (special === Special.FALSE) return false; throw new Error("bad special"); } if (valueBuilder.hasOctets()) { return new Uint8Array(valueBuilder.getOctets_asU8()); } if (valueBuilder.hasDocument()) { const document = valueBuilder.getDocument(); const keys = document.getKeysList(); const values = document.getValuesList(); const result = new Map(); for (let i = 0; i < keys.length; i++) { result.set(unwrapKey(keys[i]), unwrapValue(values[i])); } return result; } if (valueBuilder.hasTuple()) { const tuple = valueBuilder.getTuple(); return tuple.getValuesList().map(unwrapValue); } if (valueBuilder.hasTimestamp()) { const epochMicros = valueBuilder.getTimestamp(); const epochMillis = epochMicros / 1000; const date = new Date(epochMillis); return date; } throw new Error("haven't implemented unwrap for this Value"); } /** * Converts a hex string (presumably encoded previously) to * an authentication token, prefixed with 'token ' * @param {string} hex hexadecimal string to convert * @returns a string 'token {token}' */ export function decodeToken(hex: string): string { ensure(hex.substring(0, 2) === "0x", "Hex string should start with 0x"); let token: string = ""; for (let i = 0; i < hex.length; i += 2) { let hexValue = hex.substring(i, i + 2); token += String.fromCharCode(parseInt(hexValue, 16)); } ensure( token.includes("token "), `Token '${token}' does not begin with 'token '`, ); return token; } /** * Encodes an authentication token as hexadecimal, prefixed by '0x'. * @param {string} token the token to encode * @returns an encoded hexadecimal string */ export function encodeToken(token: string): string { let result: string = "0x"; if (!token.includes("token ")) { token = "token " + token; } for (let i = 0; i < token.length; i++) { let hex = token.charCodeAt(i).toString(16); result += hex.padStart(2, "0"); } return result; } /** * Converts from any javascript value Gink can store into the corresponding proto builder. * @param arg Any Javascript value Gink can store * @returns */ export function wrapValue(arg: Value): ValueBuilder { ensure(arg !== undefined); const valueBuilder = new ValueBuilder(); if (arg instanceof Uint8Array) { return valueBuilder.setOctets(arg); } if (arg instanceof Date) { return valueBuilder.setTimestamp(arg.getTime() * 1000); } if (arg === null) { return valueBuilder.setSpecial(Special.NULL); } if (arg === true) { return valueBuilder.setSpecial(Special.TRUE); } if (arg === false) { return valueBuilder.setSpecial(Special.FALSE); } if (typeof arg === "string") { return valueBuilder.setCharacters(arg); } if (typeof arg === "number") { return valueBuilder.setFloating(arg); } if (typeof arg === "bigint") { return valueBuilder.setInteger(arg.toString()); } if (Array.isArray(arg)) { const tupleBuilder = new TupleBuilder(); tupleBuilder.setValuesList(arg.map(wrapValue)); return valueBuilder.setTuple(tupleBuilder); } if (arg instanceof Map) { const documentBuilder = new DocumentBuilder(); for (const [key, val] of arg.entries()) { if (typeof key !== "number" && typeof key !== "string") { throw new Error("keys in documents must be numbers or strings"); } documentBuilder.addKeys(wrapKey(key)); documentBuilder.addValues(wrapValue(val)); } return valueBuilder.setDocument(documentBuilder); } if (typeof arg === "object") { if (Object.getPrototypeOf(arg) !== Object.prototype) { throw new Error(`Don't know how to serialize: ${arg}`); } const documentBuilder = new DocumentBuilder(); for (const [key, val] of Object.entries(arg)) { documentBuilder.addKeys(wrapKey(key)); documentBuilder.addValues(wrapValue(<Value>val)); } return valueBuilder.setDocument(documentBuilder); } throw new Error(`don't know how to wrap: ${arg}`); } export function isDate(value: any): boolean { return ( typeof value === "object" && Object.prototype.toString.call(value) === "[object Date]" ); } export function matches(a: any[], b: any[]) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } export function pairKeyToArray(storageKey: String): Array<Muid> { const split = storageKey.split(","); ensure(split.length === 2); return [strToMuid(split[0]), strToMuid(split[1])]; } /** * Converts a Muid object to its canonical string representation * Refer to docs/muid.md * @param muid * @returns a string of the canonical string representation */ export function muidToString(muid: Muid): string { let timestamp = intToHex(muid.timestamp, TIMESTAMP_HEX_DIGITS); let medallion = intToHex(muid.medallion, MEDALLION_HEX_DIGITS); let offset = intToHex(muid.offset, OFFSET_HEX_DIGITS); let result = `${timestamp}-${medallion}-${offset}`; ensure(result.length === 34, `${result} isn't 34 characters long`); return result; } export function muidTupleToString(muidTuple: MuidTuple): string { let timestamp: string; if (muidTuple[0] === Infinity || muidTuple[0] === -1) { timestamp = "F".repeat(TIMESTAMP_HEX_DIGITS); } else { timestamp = intToHex(muidTuple[0], TIMESTAMP_HEX_DIGITS); } let medallion = intToHex(muidTuple[1], MEDALLION_HEX_DIGITS); let offset = intToHex(muidTuple[2], OFFSET_HEX_DIGITS); return `${timestamp}-${medallion}-${offset}`; } export function strToMuidTuple(value: string): MuidTuple { const nums = value.split("-"); return [ muidHexToInt(nums[0]), muidHexToInt(nums[1]), muidHexToInt(nums[2]), ]; } export function strToMuid(value: string): Muid { const nums = value.split("-"); return { timestamp: muidHexToInt(nums[0]), medallion: muidHexToInt(nums[1]), offset: muidHexToInt(nums[2]), }; } /** * Converts a hexadecimal string to an integer. String should * not contain more than 14 characters. * @param hexString hexadecimal string <= 14 characters * @returns a signed integer. */ function muidHexToInt(hexString: string): number { ensure(hexString.length <= 14); let beginningAddition = BigInt(0); if (hexString.length === 14) { let beginning = hexString.substring(0, 1); hexString = hexString.substring(1); if (beginning === "1") { beginningAddition = BigInt(16) ** BigInt(14); } } let len = hexString.length; let mod = BigInt(16) ** BigInt(len); let num = BigInt(parseInt(hexString, 16)); mod = mod * (num > mod >> BigInt(1) ? BigInt(1) : BigInt(0)); return Number(num + beginningAddition - mod); } /** * Converts a number to its hexadecimal equivalent. * @param value * @param padding maximum size of hex string, padded by 0s. * @returns a hexadecimal string */ export function intToHex(value: number, padding?: number): string { const digits = padding || 0; const twosComplement = value < 0 ? BigInt(16) ** BigInt(digits) + BigInt(value) : value; return twosComplement.toString(16).padStart(digits, "0").toUpperCase(); } export const oneByteToHex = (byte: number) => byte.toString(16).padStart(2, "0").toUpperCase(); export const bytesToHex = (bytes: Uint8Array) => Array.from(bytes).map(oneByteToHex).join(""); export const parseByte = (twoHexDigits: string) => parseInt(twoHexDigits, 16); export const hexToBytes = (hex: string) => Uint8Array.from(hex.match(/.{1,2}/g).map(parseByte)); export function timestampToString(timestamp: Timestamp): string { return intToHex(timestamp, 14); } export function valueToJson(value: Value): string { // Note that this function doesn't check for circular references or anything like that, but // I think this is okay because circular objects can't be encoded into the database in the first place. if (value instanceof Uint8Array) { value = Array.from(value).map(intToHex).join(""); } const type = typeof value; if (type === "bigint") { return String(value); } if ( type === "string" || type === "number" || value === true || value === false || value === null ) { return JSON.stringify(value); } if ("function" === typeof value["toISOString"]) { return `"${(value as Date).toISOString()}"`; } if (Array.isArray(value)) { return "[" + value.map(valueToJson).join(",") + "]"; } if (value instanceof Map || value[Symbol.toStringTag] === "Map") { const entries = Array.from(value["entries"]()); entries.sort(); return ( "{" + entries .map(function (pair) { return `"${pair[0]}":` + valueToJson(pair[1]); }) .join(",") + "}" ); } throw new Error(`value not recognized: ${value}`); } export function muidToTuple(muid: Muid): MuidTuple { return [muid.timestamp, muid.medallion, muid.offset]; } export function muidTupleToMuid(tuple: MuidTuple): Muid { return { timestamp: tuple[0], medallion: tuple[1], offset: tuple[2], }; } /** * Checks the resource path to ensure that it will resolve to a sensible file. * Specifically, it will require that each path component start with [a-zA-Z0-9_], * and only allow [a-zA-Z0-9_.@-] for following characters. This is to prevent * users from accessing hidden files with a dot prefix and traversing up with dot-dot * @param path resource requested * @returns True if the path doesn't look like something we should let users access. */ export function isPathDangerous(path: string): boolean { const pathParts = path.split(/\/+/).filter((part) => part.length > 0); return ( pathParts.length === 0 || !pathParts.every((part) => /^\w[\w.@-]*$/.test(part)) ); } /** * Uses `console.error` to log messages to stderr in a form like: * [04:07:03.227Z CommandLineInterface.ts:51] got chain manager, using medallion=383316229311328 * That is to say, it's: * [<Timestamp> <SourceFileName>:<SourceLine>] <Message> * @param msg message to log */ export function logToStdErr(msg: string) { const stackString = new Error().stack; const callerLine = stackString ? stackString.split("\n")[2] : ""; const caller = callerLine .split(/\//) .pop() ?.replace(/:\d+\)/, ""); const timestamp = new Date().toISOString().split("T").pop(); // using console.error because I want to write to stderr const procId = process ? process.pid : 0; console.error(`[${timestamp} ${caller} ${procId}] ${msg}`); } export function sameData(key1: any, key2: any): boolean { if (key1 instanceof Uint8Array && key2 instanceof Uint8Array) { if (key1.byteLength !== key2.byteLength) return false; for (let i = 0; i < key1.byteLength; i++) { if (key1[i] !== key2[i]) return false; } return true; } if (Array.isArray(key1) && Array.isArray(key2)) { if (key1.length !== key2.length) return false; for (let i = 0; i < key1.length; i++) { if (!sameData(key1[i], key2[i])) return false; } return true; } if ( typeof key1 === "number" || typeof key1 === "string" || typeof key1 === "undefined" ) { return key1 === key2; } return false; } export function entryToEdgeData(entry: Entry): EdgeData { return { source: muidTupleToMuid(entry.sourceList[0]), target: muidTupleToMuid(entry.targetList[0]), value: entry.value, etype: muidTupleToMuid(entry.containerId), effective: <number>entry.storageKey, }; } export const dehydrate = muidToTuple; export const rehydrate = muidTupleToMuid; export function getActorId(): ActorId { if (typeof window === "undefined") return process.pid; else { // So we don't assign multiple gink instances in different windows the same actorId if (!window.localStorage.getItem(`gink-current-window`)) { window.localStorage.setItem(`gink-current-window`, "1"); } let currentWindow = Number( window.localStorage.getItem(`gink-current-window`), ); // Using 2^22 since that is the max pid for any process on a 64 bit machine. const aId = 2 ** 22 + currentWindow; currentWindow++; window.localStorage.setItem( `gink-current-window`, String(currentWindow), ); window.localStorage.setItem(`gink-${aId}`, `${Date.now()}`); // Heartbeat the browser's localStorage every 1 second with the current time. // This is to tell isAlive() that the window is still alive. setInterval(() => { window.localStorage.setItem(`gink-${aId}`, `${Date.now()}`); }, 1000); window.onunload = () => { window.localStorage.removeItem(`gink-${aId}`); }; return aId; } } /** * Used to (attempt to) identify the user who starts a gink chain. * @returns either the 'username@hostname' of the process running gink, * or a generic 'browser-client' if gink is running in a browser. */ export function getIdentity(): string { if (typeof window === "undefined") return `${userInfo().username}@${hostname()}`; else { return ( "browser-client-" + "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => ( +c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) ).toString(16), ) ); } } /** * This function exists to determine if the process or window that previously wrote to a chain is still around. * If not, then it's safe to append to that chain (to reduce the number of chain starts). If the creator of a * chain is still active, then you can't assume that the chain is free for reuse. * @param actorId * @returns */ export async function isAlive(actorId: ActorId): Promise<boolean> { if (typeof window === "undefined") { ensure(findProcess, "find-process library didn't load in browser"); const found = await findProcess("pid", actorId); ensure(found.length === 0 || found.length === 1); return found.length === 1; } else { const lastPinged = window.localStorage.getItem(`gink-${actorId}`); if (!lastPinged) return false; const lastPingedTime = Number(lastPinged); const currentTime = Date.now(); // Compare current time to the last window heartbeat // Using 5 seconds here for a bit of a buffer return currentTime - lastPingedTime < 5000; } } export function getType(extension: string) { const types = { html: "text/html", css: "text/css", js: "application/javascript", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", json: "application/json", xml: "application/xml", }; const result = types[extension]; if (!result) { throw new Error(`type not found for extension: ${extension}`); } return result; } export function mergeBytes(arrayOne: Bytes, arrayTwo: Bytes): Bytes { const mergedArray = new Uint8Array(arrayOne.length + arrayTwo.length); mergedArray.set(arrayOne); mergedArray.set(arrayTwo, arrayOne.length); return mergedArray; } export function signBundle(message: Bytes, secretKey: Bytes): Bytes { if (secretKey.length != 64) throw new Error("secret key not appropriate length!"); if (signingBundles) { //return mergeBytes(secretKey, message); return crypto_sign(message, secretKey); } else return message; } export function verifyBundle(signedBundle: Bytes, verifyKey: Bytes) { ensure(verifyKey.length == 32); if (signingBundles) { crypto_sign_open(signedBundle, verifyKey); } } export function createKeyPair(): KeyPair { const result = crypto_sign_keypair(); ensure( bytesToHex(result.privateKey).endsWith(bytesToHex(result.publicKey)), ); return { publicKey: result.publicKey, secretKey: result.privateKey, }; /* //uncomment for deterministic debugging const x = '5FF46DD6A05CCA09822D96CA4AF957D4ED22E059B1D82AA8DD692FF092B5A15C'; const y = '26F20F23EB12D508DF46DB9EE51BCA3E005AD00845F8A92A1E0E3E2440FE35E0'; return { secretKey: hexToBytes( x + y), publicKey: hexToBytes(y), } */ } export function getSig(bytes: Bytes): number { let result = 0; for (let i = 0; i < bytes.byteLength; i++) { result = result ^ bytes[i]; } return result; } export function encryptMessage(message: string | Bytes, key: Bytes): Bytes { let nonce = randombytes_buf(crypto_secretbox_NONCEBYTES); nonce = randombytes_buf(crypto_secretbox_NONCEBYTES); const ciphertext = crypto_secretbox_easy(message, nonce, key); return mergeBytes(nonce, ciphertext); } export function decryptMessage(message: Bytes, key: Bytes): Bytes { if ( message.length < crypto_secretbox_NONCEBYTES + crypto_secretbox_MACBYTES ) { throw new Error("Message length shorter than nonce + MAC"); } let nonce = message.slice(0, crypto_secretbox_NONCEBYTES), ciphertext = message.slice(crypto_secretbox_NONCEBYTES); return crypto_secretbox_open_easy(ciphertext, nonce, key); } export function concatenate(a: Bytes, b: Bytes): Bytes { const c = new Uint8Array(a.length + b.length); c.set(a, 0); c.set(b, a.length); return c; }