diff --git a/LICENSE b/LICENSE index 2071b23..8995c8b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,21 @@ MIT License -Copyright (c) +Copyright (c) 2022 Hammer Technologies LLC -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/build.js b/build.js new file mode 100644 index 0000000..0964e41 --- /dev/null +++ b/build.js @@ -0,0 +1,15 @@ +import esbuild from "esbuild"; + +esbuild.buildSync({ + entryPoints: ["src-module/index.ts"], + outfile: "dist-module/index.js", + format: "esm", + bundle: true, + legalComments: "external", + // minify: true + inject: ["./polyfill.js"], + tsconfig: "tsconfig.module.json", + define: { + global: "self", + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..397b4c1 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "@lumeweb/resolver-module-eip137", + "version": "0.1.0", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build-script": "tsc --project tsconfig.build.json && mv dist-build/build.js dist-build/build.mjs", + "compile-module": "npm run build-script && node build.js", + "build": "tsc --project tsconfig.json", + "build-module": "npm run compile-module && node ./dist-build/build.mjs dev" + }, + "dependencies": { + "@lumeweb/ensjs": "^2.1.2", + "@lumeweb/kernel-eth-client": "git+https://git.lumeweb.com/LumeWeb/kernel-eth-client.git", + "@lumeweb/kernel-libresolver": "git+https://git.lumeweb.com/LumeWeb/kernel-libresolver.git", + "@lumeweb/libresolver": "git+https://git.lumeweb.com/LumeWeb/libresolver.git", + "ethers": "^6.2.3", + "libskynet": "^0.1.9" + }, + "devDependencies": { + "@scure/bip39": "^1.2.0", + "@skynetlabs/skynet-nodejs": "^2.9.0", + "@types/node": "^18.15.10", + "@types/read": "^0.0.29", + "buffer": "^6.0.3", + "cli-progress": "^3.12.0", + "crypto-browserify": "^3.12.0", + "esbuild": "^0.15.18", + "libskynetnode": "^0.1.4", + "os-browserify": "^0.3.0", + "prettier": "^2.8.7", + "process": "^0.11.10", + "read": "^1.0.7", + "stream-browserify": "^3.0.0", + "typescript": "^5.0.2" + }, + "browser": { + "crypto": "crypto-browserify", + "stream": "stream-browserify", + "os": "os-browserify" + } +} diff --git a/polyfill.js b/polyfill.js new file mode 100644 index 0000000..c9d9300 --- /dev/null +++ b/polyfill.js @@ -0,0 +1,2 @@ +export const process = require("process"); +export const Buffer = require("buffer").Buffer; diff --git a/src-build/build.ts b/src-build/build.ts new file mode 100644 index 0000000..b8b4d6f --- /dev/null +++ b/src-build/build.ts @@ -0,0 +1,218 @@ +// This is the standard build script for a kernel module. + +import * as fs from "fs"; +import read from "read"; +import * as bip39 from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; +//@ts-ignore +import { SkynetClient } from "@skynetlabs/skynet-nodejs"; + +// Helper variables to make it easier to return empty values alongside errors. +const nu8 = new Uint8Array(0); +const nkp = { + publicKey: nu8, + secretKey: nu8, +}; + +// readFile is a wrapper for fs.readFileSync that handles the try-catch for the +// caller. +function readFile(fileName: string): [string, string | null] { + try { + let data = fs.readFileSync(fileName, "utf8"); + return [data, null]; + } catch (err) { + return ["", "unable to read file: " + JSON.stringify(err)]; + } +} + +// readFileBinary is a wrapper for fs.readFileSync that handles the try-catch +// for the caller. +function readFileBinary(fileName: string): [Uint8Array, string | null] { + try { + let data = fs.readFileSync(fileName, null); + return [data, null]; + } catch (err) { + return [nu8, "unable to read file: " + JSON.stringify(err)]; + } +} + +// writeFile is a wrapper for fs.writeFileSync which handles the try-catch in a +// non-exception way. +function writeFile(fileName: string, fileData: string): string | null { + try { + fs.writeFileSync(fileName, fileData); + return null; + } catch (err) { + return "unable to write file: " + JSON.stringify(err); + } +} + +// handlePass handles all portions of the script that occur after the password +// has been requested. If no password needs to be requested, handlePass will be +// called with a null input. We need to structure the code this way because the +// password reader is async and we can only access the password when using a +// callback. +function handlePass(password: string) { + try { + // If we are running prod and the seed file does not exist, we + // need to confirm the password and also warn the user to use a + // secure password. + if (!fs.existsSync(seedFile) && process.argv[2] === "prod") { + // The file does not exist, we need to confirm the + // password. + console.log(); + console.log( + "No production entry found for module. Creating new production module..." + ); + console.log( + "If someone can guess the password, they can push arbitrary changes to your module." + ); + console.log("Please use a secure password."); + console.log(); + read( + { prompt: "Confirm Password: ", silent: true }, + function (err: any, confirmPassword: string) { + if (err) { + console.error("unable to fetch password:", err); + process.exit(1); + } + if (password !== confirmPassword) { + console.error("passwords do not match"); + process.exit(1); + } + handlePassConfirm(moduleSalt, password); + } + ); + } else { + // If the seed file does exist, or if we are using dev, + // there's no need to confirm the password but we do + // need to pass the logic off to the handlePassConfirm + // callback. + handlePassConfirm(moduleSalt, password); + } + } catch (err) { + console.error("Unable to read seedFile:", err); + process.exit(1); + } +} + +// handlePassConfirm handles the full script after the confirmation password +// has been provided. If not confirmation password is needed, this function +// will be called anyway using the unconfirmed password as input. +function handlePassConfirm(seed: string, password: string) { + // Create the seedFile if it does not exist. For dev we just save the + // seed to disk outright, because this is a dev build and therefore not + // security sensitive. Also the dev seed does not get pushed to the + // github repo. + // + // For prod, we use the seed to create a new seed (called the shield) + // which allows us to verify that the developer has provided the right + // password when deploying the module. The shield does get pushed to + // the github repo so that the production module is the same on all + // devices. + if (!fs.existsSync(seedFile) && process.argv[2] !== "prod") { + // Generate the seed phrase and write it to the file. + let seedPhrase = bip39.generateMnemonic(wordlist); + let errWF = writeFile(seedFile, seedPhrase); + if (errWF !== null) { + console.error("unable to write file:", errWF); + process.exit(1); + } + } else if (!fs.existsSync(seedFile) && process.argv[2] === "prod") { + // Generate the seed phrase. + let seedPhrase = bip39.generateMnemonic(wordlist); + // Write the registry link to the file. + } + + // Load or verify the seed. If this is prod, the password is used to + // create and verify the seed. If this is dev, we just load the seed + // with no password. + let seedPhrase: string; + let registryLink: string; + if (process.argv[2] === "prod") { + // Generate the seed phrase from the password. + seedPhrase = bip39.generateMnemonic(wordlist); + } else { + let [sp, errRF] = readFile(seedFile); + if (errRF !== null) { + console.error("unable to read seed phrase for dev command from disk"); + process.exit(1); + } + seedPhrase = sp; + } + + let metadata = { + Filename: "index.js", + }; + const client = new SkynetClient("https://web3portal.com"); + client + .uploadFile("dist-module/index.js") + .then((result: any) => { + console.log("Immutable Link for kernel:", result); + }) + .catch((err: any) => { + console.error("unable to upload file", err); + process.exit(1); + }); +} + +// Add a newline for readability. +console.log(); + +// Check for a 'dev' or 'prod' input to the script. +if (process.argv.length !== 3) { + console.error("need to provide either 'dev' or 'prod' as an input"); + process.exit(1); +} + +// Create the build folder if it does not exist. +if (!fs.existsSync("build")) { + fs.mkdirSync("build"); +} + +// Determine the seed file. +let seedFile: string; +if (process.argv[2] === "prod") { + seedFile = "module-skylink"; +} else if (process.argv[2] === "dev") { + seedFile = "build/dev-seed"; +} else { + console.error("need to provide either 'dev' or 'prod' as an input"); + process.exit(1); +} + +// If doing a prod deployment, check whether the salt file exists. If it does +// not, create it. +let moduleSalt: string; +if (!fs.existsSync(".module-salt")) { + moduleSalt = bip39.generateMnemonic(wordlist); + let errWF = writeFile(".module-salt", moduleSalt); + if (errWF !== null) { + console.error("unable to write module salt file:", errWF); + process.exit(1); + } +} else { + let [ms, errRF] = readFile(".module-salt"); + if (errRF !== null) { + console.error("unable to read moduleSalt"); + process.exit(1); + } + ms = ms.replace(/\n$/, ""); + moduleSalt = ms; +} + +// Need to get a password if this is a prod build. +if (process.argv[2] === "prod") { + read( + { prompt: "Password: ", silent: true }, + function (err: any, password: string) { + if (err) { + console.error("unable to fetch password:", err); + process.exit(1); + } + handlePass(password); + } + ); +} else { + handlePass(""); +} diff --git a/src-module/index.ts b/src-module/index.ts new file mode 100644 index 0000000..9b0af77 --- /dev/null +++ b/src-module/index.ts @@ -0,0 +1,5 @@ +import { setup } from "@lumeweb/kernel-libresolver"; +import Eth from "../src/index.js"; + +// @ts-ignore +setup(new Eth()); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f994360 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,134 @@ +import { + AbstractResolverModule, + DNS_RECORD_TYPE, + DNSRecord, + DNSResult, + isPromise, + resolverEmptyResponse, + resolverError, + ResolverOptions, + resolveSuccess, +} from "@lumeweb/libresolver"; +import { ethers } from "ethers"; +import { createProvider } from "@lumeweb/kernel-eth-client"; +// @ts-ignore +import ENSRoot, { getEnsAddress } from "@lumeweb/ensjs"; + +const ENS = ENSRoot.default; + +const ETH_PROVIDER = createProvider(); +// @ts-ignore +ETH_PROVIDER._isProvider = true; + +const ETH_SIGNER = new ethers.VoidSigner(ethers.ZeroAddress, ETH_PROVIDER); +// @ts-ignore +ETH_SIGNER._isSigner = true; + +// @ts-ignore +ETH_PROVIDER.getSigner = () => ETH_SIGNER; + +export default class Eth extends AbstractResolverModule { + // @ts-ignore + getSupportedTlds(): string[] { + return ["eth"]; + } + + async resolve( + domain: string, + options: ResolverOptions, + bypassCache: boolean + ): Promise { + const hip5Data = domain.split("."); + if ( + 2 <= hip5Data.length && + options.options && + "domain" in options.options + ) { + if (ethers.isAddress(hip5Data[0])) { + let chain = hip5Data[1].replace("_", ""); + domain = options.options.domain; + + if (chain !== "eth") { + return resolverError("HIP5 chain not supported"); + } + return this.resolve137(domain, options, bypassCache); + } + } + + if (await this.isTldSupported(domain)) { + return this.resolve137(domain, options, bypassCache); + } + + return resolverEmptyResponse(); + } + + private async resolve137( + domain: string, + options: ResolverOptions, + bypassCache: boolean + ): Promise { + const records: DNSRecord[] = []; + + const ens = new ENS({ + provider: ETH_PROVIDER, + ensAddress: getEnsAddress(1), + }); + + let name; + try { + name = await ens.name(domain); + } catch (e: any) { + return resolverError(e); + } + + let content; + if ( + [DNS_RECORD_TYPE.CONTENT, DNS_RECORD_TYPE.TEXT].includes(options.type) + ) { + try { + content = maybeGetContentHash(await name.getContent()); + } catch (e: any) { + return resolverError(e); + } + + records.push({ + type: DNS_RECORD_TYPE.CONTENT, + value: content as string, + }); + } + + if ([DNS_RECORD_TYPE.CUSTOM].includes(options.type)) { + let text; + try { + text = await name.getText(options.customType); + } catch (e: any) { + return resolverError(e); + } + + records.push({ + type: options.type, + customType: options.customType, + value: content as string, + }); + } + + if (0 < records.length) { + return resolveSuccess(records); + } + + return resolverEmptyResponse(); + } +} + +export function maybeGetContentHash(contentResult: any): string | boolean { + let content = false; + + if ( + typeof contentResult === "object" && + "contenthash" === contentResult.contentType + ) { + content = contentResult.value; + } + + return content; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..56508e4 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "esnext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "declaration": true, + "outDir": "./dist-build", + "strict": true + }, + "include": ["src-build"], + "exclude": ["node_modules", "**/__tests__/*"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..34d7221 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2021", + "declaration": true, + "moduleResolution": "nodenext", + "outDir": "./dist", + "strict": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"], + "exclude": ["node_modules", "**/__tests__/*"] +} diff --git a/tsconfig.module.json b/tsconfig.module.json new file mode 100644 index 0000000..22867ed --- /dev/null +++ b/tsconfig.module.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2021", + "declaration": true, + "moduleResolution": "node", + "outDir": "./dist-module", + "strict": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src-module"], + "exclude": ["node_modules", "**/__tests__/*"] +}