UNPKG

@planetarium/account-web3-secret-storage

Version:

Libplanet account implementation using Ethereum Web3 Secret Storage

670 lines (661 loc) 21.2 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); // src/crypto/browser.ts var crypto = window.crypto; // src/KeyId.ts var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function isKeyId(keyId) { return !!keyId.match(UUID_PATTERN); } __name(isKeyId, "isKeyId"); function generateKeyId() { return crypto.randomUUID(); } __name(generateKeyId, "generateKeyId"); // src/TtyPassphraseEntry.ts import * as readline from "node:readline"; import { Writable } from "node:stream"; function isNewLine(chunk) { return typeof chunk === "string" && chunk.endsWith("\n") || chunk instanceof Buffer && chunk.at(chunk.length - 1) === 10; } __name(isNewLine, "isNewLine"); function readPassphrase(prompt, { input, output, mask }) { let masking = false; const rl = readline.createInterface({ input, output: new Writable({ write: (chunk, encoding, callback) => { if (masking && !isNewLine(chunk)) { output.write( mask, encoding.endsWith("buffer") ? "utf8" : encoding, callback ); } else output.write(chunk, encoding, callback); } }), terminal: true }); return new Promise((resolve) => { rl.question(prompt, (answer) => { rl.close(); resolve(answer); }); setTimeout(() => masking = mask !== false, 0); }); } __name(readPassphrase, "readPassphrase"); var prompts = { authenticate: { "": "Passphrase for {keyId}: ", ko: "{keyId} \uD0A4\uC758 \uC554\uD638: ", qts: "AUTHENTICATE:{keyId}" // for unit test }, authenticateRetry: { "": "Incorrect; try again: ", ko: "\uD2C0\uB9BC. \uC7AC\uC2DC\uB3C4: ", qts: "AUTHENTICATE_RETRY:{keyId}" // for unit test }, configurePassphrase: { "": "New passphrase: ", ko: "\uC0C8 \uC554\uD638: ", qts: "CONFIGURE_PASSPHRASE" // for unit test }, configurePassphraseRetry: { "": "Mismatch; new passphrase: ", ko: "\uD2C0\uB9BC. \uC0C8 \uC554\uD638 \uC7AC\uC785\uB825:", qts: "CONFIGURE_PASSPHRASE_RETRY" // for unit test }, confirmConfigurePassphrase: { "": "Confirm passphrase: ", ko: "\uC554\uD638 \uD655\uC778:", qts: "CONFIRM_CONFIGURE_PASSPHRASE" // for unit test } }; function getLocale() { return Intl.DateTimeFormat().resolvedOptions().locale; } __name(getLocale, "getLocale"); var TtyPassphraseEntry = class { #options; constructor(options = {}) { this.#options = { input: options.input ?? process.stdin, output: options.output ?? process.stdout, mask: options.mask ?? "*", locale: options.locale ?? getLocale() }; } #getPrompt(promptType) { const table = prompts[promptType]; let locale = this.#options.locale; let msg; while (msg == null) { msg = table[locale]; locale = locale.replace(/(^|[-_.])[^-_.]*$/, ""); } return msg; } authenticate(keyId, firstAttempt) { const message = this.#getPrompt( firstAttempt ? "authenticate" : "authenticateRetry" ); const prompt = message.replace(/\{keyId\}/g, keyId); return readPassphrase(prompt, this.#options); } async configurePassphrase() { let prompt = this.#getPrompt("configurePassphrase"); const retryPrompt = this.#getPrompt("configurePassphraseRetry"); const confirmPrompt = this.#getPrompt("confirmConfigurePassphrase"); while (true) { const passphrase = await readPassphrase(prompt, this.#options); const confirmPassphrase = await readPassphrase( confirmPrompt, this.#options ); if (passphrase === confirmPassphrase) return passphrase; prompt = retryPrompt; } } }; __name(TtyPassphraseEntry, "TtyPassphraseEntry"); // src/Web3Account.ts import { Address, RawPrivateKey } from "@planetarium/account"; import { pbkdf2Async } from "@noble/hashes/pbkdf2"; import { scryptAsync } from "@noble/hashes/scrypt"; import { sha256 } from "@noble/hashes/sha256"; import { keccak_256 } from "@noble/hashes/sha3"; var Web3Account = class { #keyObject; #passphraseEntry; #options; constructor(keyObject, passphraseEntry, options = {}) { this.#keyObject = keyObject; this.#passphraseEntry = passphraseEntry; this.#options = options; } async #exportPrivateKey(options = {}) { let firstAttempt = true; let privateKey; while (true) { const passphrase = await this.#passphraseEntry.authenticate( this.#keyObject.id, firstAttempt ); try { const result = await decryptKeyObject(this.#keyObject, passphrase, { ...this.#options, ...options }); privateKey = result.privateKey; } catch (e) { if (e instanceof IncorrectPassphraseError) { firstAttempt = false; continue; } throw e; } break; } return privateKey; } exportPrivateKey() { return this.#exportPrivateKey({ allowWeakPrivateKey: true }); } getAddress() { return Promise.resolve(Address.fromHex(this.#keyObject.address, true)); } async getPublicKey() { const key = await this.#exportPrivateKey({ allowWeakPrivateKey: true }); return await key.getPublicKey(); } async sign(message) { const key = await this.#exportPrivateKey(); return await key.sign(message); } }; __name(Web3Account, "Web3Account"); function toHex(bytes) { const array = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let hex = ""; for (let i = 0; i < array.length; i++) { hex += array[i].toString(16).padStart(2, "0"); } return hex; } __name(toHex, "toHex"); async function encryptKeyObject(keyId, privateKey, passphrase) { if (!isKeyId(keyId)) { throw new Error(`Invalid key ID: ${keyId}`); } const salt = new Uint8Array(32); crypto.getRandomValues(salt); const kdf = { kdf: "pbkdf2", kdfparams: { c: 10240, dklen: 32, prf: "hmac-sha256", salt: toHex(salt) } }; const derivedKey = await deriveKey(kdf, passphrase); const cipher = await encipher({ derivedKey, privateKey: privateKey.toBytes() }); const address = await Address.deriveFrom(privateKey); return { version: 3, id: keyId, address: address.toHex("lower"), crypto: { ...cipher, ...kdf } }; } __name(encryptKeyObject, "encryptKeyObject"); function isKeyObject(json) { if (typeof json !== "object" || json == null || !("version" in json) || json.version !== 3 || !("id" in json) || typeof json.id !== "string" || !isKeyId(json.id) || !("address" in json) || typeof json.address !== "string") { return false; } try { Address.fromHex(json.address, true); } catch (e) { return false; } return "crypto" in json && typeof json.crypto === "object" && isKeyObjectCipher(json.crypto) && isKeyObjectKdf(json.crypto); } __name(isKeyObject, "isKeyObject"); function isKeyObjectCipher(json) { return typeof json === "object" && json != null && "cipher" in json && json.cipher === "aes-128-ctr" && "cipherparams" in json && typeof json.cipherparams === "object" && json.cipherparams != null && "iv" in json.cipherparams && typeof json.cipherparams.iv === "string" && "ciphertext" in json && typeof json.ciphertext === "string" && "mac" in json && typeof json.mac === "string"; } __name(isKeyObjectCipher, "isKeyObjectCipher"); function isKeyObjectKdf(json) { if (typeof json !== "object" || json == null || !("kdf" in json)) { return false; } switch (json.kdf) { case "pbkdf2": return "kdfparams" in json && typeof json.kdfparams === "object" && json.kdfparams != null && "c" in json.kdfparams && typeof json.kdfparams.c === "number" && "dklen" in json.kdfparams && typeof json.kdfparams.dklen === "number" && "prf" in json.kdfparams && json.kdfparams.prf === "hmac-sha256" && "salt" in json.kdfparams && typeof json.kdfparams.salt === "string"; case "scrypt": return "kdfparams" in json && typeof json.kdfparams === "object" && json.kdfparams != null && "dklen" in json.kdfparams && typeof json.kdfparams.dklen === "number" && "n" in json.kdfparams && typeof json.kdfparams.n === "number" && "p" in json.kdfparams && typeof json.kdfparams.p === "number" && "r" in json.kdfparams && typeof json.kdfparams.r === "number" && "salt" in json.kdfparams && typeof json.kdfparams.salt === "string"; default: return false; } } __name(isKeyObjectKdf, "isKeyObjectKdf"); var WeakPrivateKeyError = class extends Error { name = "WeakPrivateKeyError"; }; __name(WeakPrivateKeyError, "WeakPrivateKeyError"); async function decryptKeyObject(keyObject, passphrase, options = {}) { if (keyObject == null) { throw new Error("Key object is null."); } else if (keyObject.version !== 3) { throw new Error(`Unsupported key object version: ${keyObject?.version}`); } else if (keyObject.crypto == null) { throw new Error("Key object does not have crypto field."); } const keyObjectAddress = Address.fromHex(keyObject.address, true); const derivedKey = await deriveKey(keyObject.crypto, passphrase); let privateKeyBytes = await decipher(keyObject.crypto, derivedKey); if (privateKeyBytes.length < 32) { const zeroPadded = new Uint8Array(32); zeroPadded.set(privateKeyBytes, 32 - privateKeyBytes.length); privateKeyBytes = zeroPadded; } if (privateKeyBytes.at(0) === 0 && !options.allowWeakPrivateKey) { throw new WeakPrivateKeyError( "The private key given is too weak; keys of length less than 32 bytes are disallowed by default. See also the Web3AccountOptions.allowWeakPrivateKey option." ); } const privateKey = RawPrivateKey.fromBytes(privateKeyBytes); const address = await Address.deriveFrom(privateKey); if (!keyObjectAddress.equals(address)) { throw new Error( `Failed to decrypt the key object; expected account ${keyObjectAddress} but got ${address} instead.` ); } return { keyId: keyObject.id, privateKey }; } __name(decryptKeyObject, "decryptKeyObject"); async function deriveKey(kdf, passphrase) { if (kdf.kdf === "pbkdf2") { const { c, dklen, prf, salt } = kdf.kdfparams; if (dklen < 16) throw new Error(`Too short dklen: ${dklen}`); if (prf !== "hmac-sha256") throw new Error(`Unsupported prf: ${prf}`); const derivedKey = await pbkdf2Async( sha256, passphrase, Buffer.from(salt, "hex"), { c, dkLen: dklen } ); if (derivedKey.length < dklen) { throw new Error(`Key too short: ${toHex(derivedKey)}`); } return derivedKey; } else if (kdf.kdf === "scrypt") { const { dklen, n, p, r, salt } = kdf.kdfparams; const derivedKey = await scryptAsync(passphrase, salt, { N: n, r, p, dkLen: dklen }); if (derivedKey.length < dklen) { throw new Error(`Key too short: ${toHex(derivedKey)}`); } return derivedKey; } throw new Error(`Unsupported kdf: ${kdf["kdf"]}`); } __name(deriveKey, "deriveKey"); var IncorrectPassphraseError = class extends Error { constructor(expectedMac, actualMac) { super(`Expected: ${toHex(expectedMac)}, Actual: ${toHex(actualMac)}`); this.expectedMac = expectedMac; this.actualMac = actualMac; } name = "IncorrectPassphraseError"; }; __name(IncorrectPassphraseError, "IncorrectPassphraseError"); async function encipher({ derivedKey, privateKey }) { const iv = new Uint8Array(16); crypto.getRandomValues(iv); const ciphertext = await crypto.subtle.encrypt( { name: "AES-CTR", counter: iv, length: 128 }, await crypto.subtle.importKey( "raw", derivedKey.subarray(0, 16), { name: "AES-CTR" }, false, ["encrypt"] ), privateKey ); return { cipher: "aes-128-ctr", cipherparams: { iv: toHex(iv) }, ciphertext: toHex(ciphertext), mac: toHex(calculateMac(derivedKey, ciphertext)) }; } __name(encipher, "encipher"); async function decipher(cipher, derivedKey) { if (cipher.cipher !== "aes-128-ctr") { throw new Error(`Unsupported cipher: ${cipher.cipher}`); } const ciphertext = new Uint8Array(Buffer.from(cipher.ciphertext, "hex")); const mac = calculateMac(derivedKey, ciphertext); const expectedMac = new Uint8Array(Buffer.from(cipher.mac, "hex")); if (!mac.every((v, i) => v === expectedMac[i])) { throw new IncorrectPassphraseError(expectedMac, mac); } const decrypted = await crypto.subtle.decrypt( { name: "AES-CTR", counter: new Uint8Array(Buffer.from(cipher.cipherparams.iv, "hex")), length: 128 }, await crypto.subtle.importKey( "raw", derivedKey.subarray(0, 16), { name: "AES-CTR" }, false, ["decrypt"] ), ciphertext ); return decrypted instanceof Uint8Array ? decrypted : new Uint8Array(decrypted); } __name(decipher, "decipher"); function calculateMac(derivedKey, ciphertext) { const keySubBytes = 16; const ciphertextBytes = ciphertext instanceof Uint8Array ? ciphertext : new Uint8Array(ciphertext); const seal = new Uint8Array(keySubBytes + ciphertextBytes.length); if (derivedKey.length < keySubBytes) { throw new Error( `Too short derived key (${derivedKey.length} < ${keySubBytes}): ${toHex( derivedKey )}` ); } seal.set(derivedKey.subarray(derivedKey.length - keySubBytes)); seal.set(ciphertextBytes, keySubBytes); const mac = keccak_256(seal); return mac; } __name(calculateMac, "calculateMac"); // src/Web3KeyStore.ts import { Address as Address2, RawPrivateKey as RawPrivateKey2 } from "@planetarium/account"; // src/path/browser.ts function join(...args) { if (args.length === 0) { return "."; } if (args.length === 1) { return args[0]; } const lastElement = args.at(-1); if (args.length > 1 && lastElement !== void 0) { return [ trimSlashEnd(args[0]), ...args.slice(1, -1).map((x) => trimSlashStart(trimSlashEnd(x))), trimSlashStart(lastElement) ].join("/"); } throw new Error("All cases are covered."); } __name(join, "join"); function trimSlashEnd(s) { let ret = s; while (ret.length > 0 && ret.at(-1) === "/") { ret = ret.slice(0, -1); } return ret; } __name(trimSlashEnd, "trimSlashEnd"); function trimSlashStart(s) { let ret = s; while (ret.length > 0 && ret.at(0) === "/") { ret = ret.slice(1); } return ret; } __name(trimSlashStart, "trimSlashStart"); function getDefaultWeb3KeyStorePath() { return "/planetarium/account/web3-secret-storage/keystore"; } __name(getDefaultWeb3KeyStorePath, "getDefaultWeb3KeyStorePath"); // src/fs/browser.ts var PREFIX = "PLANETARIUM_EMULATED_FS_"; function prefixed(str) { return PREFIX + str; } __name(prefixed, "prefixed"); async function mkdir(path, options) { } __name(mkdir, "mkdir"); async function readFile(path, options) { const item = localStorage.getItem(prefixed(path)); if (item == null) { throw new Error("Not found"); } return item; } __name(readFile, "readFile"); async function removeFile(path) { localStorage.removeItem(prefixed(path)); } __name(removeFile, "removeFile"); async function* listFiles(directory) { for (let i = 0; i < localStorage.length; ++i) { const item = localStorage.key(i); if (item == null) { throw new Error(`Expected ${i}th item in localStorage.`); } if (item.startsWith(prefixed(directory))) { let sliced = item.slice(prefixed(directory).length); if (sliced.startsWith("/")) { sliced = sliced.slice(1); } yield sliced; } } } __name(listFiles, "listFiles"); async function writeFile(path, content, encoding) { localStorage.setItem(prefixed(path), content); } __name(writeFile, "writeFile"); // src/Web3KeyStore.ts var pattern = /^(?:UTC--([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}-[0-9]{2}-[0-9]{2})Z--)?([0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12})(?:.json)?$$/i; function parseKeyFilename(name) { const match = pattern.exec(name); if (match == null) return void 0; return { keyId: match[3].toLowerCase(), createdAt: match[1] != null && match[2] != null ? /* @__PURE__ */ new Date(`${match[1]}T${match[2].replace(/-/g, ":")}Z`) : void 0 }; } __name(parseKeyFilename, "parseKeyFilename"); var Web3KeyStore = class { #passphraseEntry; #accountOptions; path; constructor(options) { this.path = options.path ?? getDefaultWeb3KeyStorePath(); this.#passphraseEntry = options.passphraseEntry; this.#accountOptions = options; } async *#listKeyFiles() { for await (const name of listFiles(this.path)) { yield name; } } async #getKeyPath(keyId) { for await (const name of this.#listKeyFiles()) { const parsed = parseKeyFilename(name); if (parsed != null && parsed.keyId === keyId) { return { ...parsed, path: join(this.path, name) }; } } return void 0; } async *list() { for await (const name of this.#listKeyFiles()) { const parsed = parseKeyFilename(name); if (parsed == null) continue; const keyPath = join(this.path, name); let json; try { json = JSON.parse(await readFile(keyPath, { encoding: "utf8" })); } catch (_) { continue; } if (!isKeyObject(json)) continue; let address; try { address = Address2.fromHex(json.address, true); } catch (_) { continue; } const metadata = { address }; yield { ...parsed, metadata }; } } async get(keyId) { const keyPath = await this.#getKeyPath(keyId); if (keyPath == null) return { result: "keyNotFound", keyId }; let json; try { json = await readFile(keyPath.path, { encoding: "utf8" }); } catch (e) { if (e != null && typeof e === "object" && "code" in e && e.code === "ENOENT") { return { result: "keyNotFound", keyId }; } return { result: "error", keyId, message: `${e}` }; } const keyObject = JSON.parse(json); if (!isKeyObject(keyObject)) { return { result: "error", keyId, message: "Invalid key file" }; } const account = new Web3Account( keyObject, this.#passphraseEntry, this.#accountOptions ); return { result: "success", account, keyId, metadata: { address: await account.getAddress() }, createdAt: keyPath.createdAt }; } async generate(metadata) { if (metadata?.address != null) { return { result: "error", message: "Address cannot be predetermined before generating key" }; } const privateKey = await RawPrivateKey2.generate(); const result = await this.#import(privateKey, metadata); if (result.result === "success") { return { result: "success", keyId: result.keyId, account: new Web3Account(result.keyObject, this.#passphraseEntry) }; } return result; } async delete(keyId) { const keyPath = await this.#getKeyPath(keyId); if (keyPath == null) return { result: "keyNotFound", keyId }; try { await removeFile(keyPath.path); } catch (e) { return { result: "error", message: `${e}` }; } return { result: "success", keyId }; } async #import(privateKey, metadata) { if (metadata?.address != null && !metadata.address.equals(await Address2.deriveFrom(privateKey))) { return { result: "error", message: "Address does not match the private key (hint: you do not have to specify it manually)" }; } const passphrase = await this.#passphraseEntry.configurePassphrase(); const keyId = generateKeyId(); const keyObject = await encryptKeyObject(keyId, privateKey, passphrase); try { await mkdir(this.path, { recursive: true }); } catch (e) { return { result: "error", message: `${e}` }; } const createdAt = /* @__PURE__ */ new Date(); const keyPath = join( this.path, `UTC--${createdAt.toISOString().replace(/\.[0-9]+Z$/, "Z").replace(/:/g, "-")}--${keyId}` ); try { await writeFile(keyPath, JSON.stringify(keyObject), "utf8"); } catch (e) { return { result: "error", message: `${e}` }; } return { result: "success", keyId, keyObject }; } async import(privateKey, metadata) { const bytes = await privateKey.toBytes(); if (bytes.at(0) === 0 && !this.#accountOptions.allowWeakPrivateKey) { return { result: "error", message: "The private key given is too weak; keys of length less than 32 bytes are disallowed by default. See also the Web3AccountOptions.allowWeakPrivateKey option." }; } const result = await this.#import(privateKey, metadata); if (result.result === "success") { return { result: "success", keyId: result.keyId }; } return result; } }; __name(Web3KeyStore, "Web3KeyStore"); export { TtyPassphraseEntry, WeakPrivateKeyError, Web3Account, Web3KeyStore, getDefaultWeb3KeyStorePath }; /*! For license information please see index.browser.mjs.LEGAL.txt */ //# sourceMappingURL=index.browser.mjs.map