@planetarium/account-web3-secret-storage
Version:
Libplanet account implementation using Ethereum Web3 Secret Storage
672 lines (662 loc) • 22.6 kB
JavaScript
;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
TtyPassphraseEntry: () => TtyPassphraseEntry,
WeakPrivateKeyError: () => WeakPrivateKeyError,
Web3Account: () => Web3Account,
Web3KeyStore: () => Web3KeyStore,
getDefaultWeb3KeyStorePath: () => getDefaultWeb3KeyStorePath
});
module.exports = __toCommonJS(src_exports);
// src/crypto/node.ts
var import_webcrypto = require("@peculiar/webcrypto");
var crypto = new import_webcrypto.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
var readline = __toESM(require("node:readline"), 1);
var import_node_stream = require("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 import_node_stream.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
var import_account = require("@planetarium/account");
var import_pbkdf2 = require("@noble/hashes/pbkdf2");
var import_scrypt = require("@noble/hashes/scrypt");
var import_sha256 = require("@noble/hashes/sha256");
var import_sha3 = require("@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(import_account.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 import_account.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 {
import_account.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 = import_account.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 = import_account.RawPrivateKey.fromBytes(privateKeyBytes);
const address = await import_account.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 (0, import_pbkdf2.pbkdf2Async)(
import_sha256.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 (0, import_scrypt.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 = (0, import_sha3.keccak_256)(seal);
return mac;
}
__name(calculateMac, "calculateMac");
// src/Web3KeyStore.ts
var import_account2 = require("@planetarium/account");
// src/path/node.ts
var import_node_path = require("node:path");
function getDefaultWeb3KeyStorePath() {
const { homedir } = require("node:os");
const path = require("node:path");
const baseDir = process.platform === "win32" ? process.env.AppData || path.join(homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
return (process.platform === "win32" ? path.win32 : path.posix).join(
baseDir,
"planetarium",
"keystore"
);
}
__name(getDefaultWeb3KeyStorePath, "getDefaultWeb3KeyStorePath");
// src/fs/node.ts
var fs = __toESM(require("node:fs/promises"), 1);
async function mkdir2(path, options) {
await fs.mkdir(path, options);
}
__name(mkdir2, "mkdir");
function readFile2(path, options) {
return fs.readFile(path, options);
}
__name(readFile2, "readFile");
function removeFile(path) {
return fs.unlink(path);
}
__name(removeFile, "removeFile");
async function* listFiles(directory) {
let dir;
try {
dir = await fs.opendir(directory);
} catch (e) {
if (typeof e === "object" && e != null && "code" in e && e.code === "ENOENT") {
return;
}
throw e;
}
for await (const dirEntry of dir) {
if (!dirEntry.isFile())
continue;
yield dirEntry.name;
}
}
__name(listFiles, "listFiles");
function writeFile2(path, content, encoding) {
return fs.writeFile(path, content, encoding);
}
__name(writeFile2, "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: (0, import_node_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 = (0, import_node_path.join)(this.path, name);
let json;
try {
json = JSON.parse(await readFile2(keyPath, { encoding: "utf8" }));
} catch (_) {
continue;
}
if (!isKeyObject(json))
continue;
let address;
try {
address = import_account2.Address.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 readFile2(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 import_account2.RawPrivateKey.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 import_account2.Address.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 mkdir2(this.path, { recursive: true });
} catch (e) {
return { result: "error", message: `${e}` };
}
const createdAt = /* @__PURE__ */ new Date();
const keyPath = (0, import_node_path.join)(
this.path,
`UTC--${createdAt.toISOString().replace(/\.[0-9]+Z$/, "Z").replace(/:/g, "-")}--${keyId}`
);
try {
await writeFile2(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");
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
TtyPassphraseEntry,
WeakPrivateKeyError,
Web3Account,
Web3KeyStore,
getDefaultWeb3KeyStorePath
});
/*! For license information please see index.cjs.LEGAL.txt */
//# sourceMappingURL=index.cjs.map