diff --git a/src-wallet/assertions.ts b/src-wallet/assertions.ts new file mode 100644 index 00000000..80602639 --- /dev/null +++ b/src-wallet/assertions.ts @@ -0,0 +1,9 @@ +import { ErrInvariantFailed } from "./errors"; + +export function guardLength(withLength: { length?: number }, expectedLength: number) { + let actualLength = withLength.length || 0; + + if (actualLength != expectedLength) { + throw new ErrInvariantFailed(`wrong length, expected: ${expectedLength}, actual: ${actualLength}`); + } +} diff --git a/src-wallet/config.ts b/src-wallet/config.ts new file mode 100644 index 00000000..cf60ce5a --- /dev/null +++ b/src-wallet/config.ts @@ -0,0 +1,15 @@ +/** + * Global configuration of the library. + * + * Generally speaking, this configuration should only be altered on exotic use cases; + * it can be seen as a collection of constants (or, to be more precise, rarely changed variables) that are used throughout the library. + * + * Never alter the configuration within a library! + * Only alter the configuration (if needed) within an (end) application that uses this library. + */ +export class LibraryConfig { + /** + * The human-readable-part of the bech32 addresses. + */ + public static DefaultAddressHrp: string = "erd"; +} diff --git a/src-wallet/crypto/constants.ts b/src-wallet/crypto/constants.ts new file mode 100644 index 00000000..9d29781d --- /dev/null +++ b/src-wallet/crypto/constants.ts @@ -0,0 +1,8 @@ +export const CipherAlgorithm = "aes-128-ctr"; +export const DigestAlgorithm = "sha256"; +export const KeyDerivationFunction = "scrypt"; + +// X25519 public key encryption +export const PubKeyEncVersion = 1; +export const PubKeyEncNonceLength = 24; +export const PubKeyEncCipher = "x25519-xsalsa20-poly1305"; diff --git a/src-wallet/crypto/decryptor.ts b/src-wallet/crypto/decryptor.ts new file mode 100644 index 00000000..0a42be1b --- /dev/null +++ b/src-wallet/crypto/decryptor.ts @@ -0,0 +1,27 @@ +import crypto from "crypto"; +import { EncryptedData } from "./encryptedData"; +import { DigestAlgorithm } from "./constants"; +import { Err } from "../errors"; + +export class Decryptor { + static decrypt(data: EncryptedData, password: string): Buffer { + const kdfparams = data.kdfparams; + const salt = Buffer.from(data.salt, "hex"); + const iv = Buffer.from(data.iv, "hex"); + const ciphertext = Buffer.from(data.ciphertext, "hex"); + const derivedKey = kdfparams.generateDerivedKey(Buffer.from(password), salt); + const derivedKeyFirstHalf = derivedKey.slice(0, 16); + const derivedKeySecondHalf = derivedKey.slice(16, 32); + + const computedMAC = crypto.createHmac(DigestAlgorithm, derivedKeySecondHalf).update(ciphertext).digest(); + const actualMAC = data.mac; + + if (computedMAC.toString("hex") !== actualMAC) { + throw new Err("MAC mismatch, possibly wrong password"); + } + + const decipher = crypto.createDecipheriv(data.cipher, derivedKeyFirstHalf, iv); + + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); + } +} diff --git a/src-wallet/crypto/derivationParams.ts b/src-wallet/crypto/derivationParams.ts new file mode 100644 index 00000000..134a9534 --- /dev/null +++ b/src-wallet/crypto/derivationParams.ts @@ -0,0 +1,36 @@ +import scryptsy from "scryptsy"; + +export class ScryptKeyDerivationParams { + /** + * numIterations + */ + n = 4096; + + /** + * memFactor + */ + r = 8; + + /** + * pFactor + */ + p = 1; + + dklen = 32; + + constructor(n = 4096, r = 8, p = 1, dklen = 32) { + this.n = n; + this. r = r; + this.p = p; + this.dklen = dklen; + } + + /** + * Will take about: + * - 80-90 ms in Node.js, on a i3-8100 CPU @ 3.60GHz + * - 350-360 ms in browser (Firefox), on a i3-8100 CPU @ 3.60GHz + */ + public generateDerivedKey(password: Buffer, salt: Buffer): Buffer { + return scryptsy(password, salt, this.n, this.r, this.p, this.dklen); + } +} diff --git a/src-wallet/crypto/encrypt.spec.ts b/src-wallet/crypto/encrypt.spec.ts new file mode 100644 index 00000000..1f785968 --- /dev/null +++ b/src-wallet/crypto/encrypt.spec.ts @@ -0,0 +1,22 @@ +import { assert } from "chai"; +import { Decryptor } from "./decryptor"; +import { EncryptedData } from "./encryptedData"; +import { Encryptor } from "./encryptor"; + +describe("test address", () => { + it("encrypts/decrypts", () => { + const sensitiveData = Buffer.from("my mnemonic"); + const encryptedData = Encryptor.encrypt(sensitiveData, "password123"); + const decryptedBuffer = Decryptor.decrypt(encryptedData, "password123"); + + assert.equal(sensitiveData.toString('hex'), decryptedBuffer.toString('hex')); + }); + + it("encodes/decodes kdfparams", () => { + const sensitiveData = Buffer.from("my mnemonic"); + const encryptedData = Encryptor.encrypt(sensitiveData, "password123"); + const decodedData = EncryptedData.fromJSON(encryptedData.toJSON()); + + assert.deepEqual(decodedData, encryptedData, "invalid decoded data"); + }); +}); diff --git a/src-wallet/crypto/encryptedData.ts b/src-wallet/crypto/encryptedData.ts new file mode 100644 index 00000000..b882a570 --- /dev/null +++ b/src-wallet/crypto/encryptedData.ts @@ -0,0 +1,65 @@ +import { ScryptKeyDerivationParams } from "./derivationParams"; + +export class EncryptedData { + id: string; + version: number; + cipher: string; + ciphertext: string; + iv: string; + kdf: string; + kdfparams: ScryptKeyDerivationParams; + salt: string; + mac: string; + + constructor(data: Omit) { + this.id = data.id; + this.version = data.version; + this.ciphertext = data.ciphertext; + this.iv = data.iv; + this.cipher = data.cipher; + this.kdf = data.kdf; + this.kdfparams = data.kdfparams; + this.mac = data.mac; + this.salt = data.salt; + } + + toJSON(): any { + return { + version: this.version, + id: this.id, + crypto: { + ciphertext: this.ciphertext, + cipherparams: { iv: this.iv }, + cipher: this.cipher, + kdf: this.kdf, + kdfparams: { + dklen: this.kdfparams.dklen, + salt: this.salt, + n: this.kdfparams.n, + r: this.kdfparams.r, + p: this.kdfparams.p + }, + mac: this.mac, + } + }; + } + + static fromJSON(data: any): EncryptedData { + return new EncryptedData({ + version: data.version, + id: data.id, + ciphertext: data.crypto.ciphertext, + iv: data.crypto.cipherparams.iv, + cipher: data.crypto.cipher, + kdf: data.crypto.kdf, + kdfparams: new ScryptKeyDerivationParams( + data.crypto.kdfparams.n, + data.crypto.kdfparams.r, + data.crypto.kdfparams.p, + data.crypto.kdfparams.dklen, + ), + salt: data.crypto.kdfparams.salt, + mac: data.crypto.mac, + }); + } +} diff --git a/src-wallet/crypto/encryptor.ts b/src-wallet/crypto/encryptor.ts new file mode 100644 index 00000000..9d6e33c1 --- /dev/null +++ b/src-wallet/crypto/encryptor.ts @@ -0,0 +1,40 @@ +import crypto from "crypto"; +import { CipherAlgorithm, DigestAlgorithm, KeyDerivationFunction } from "./constants"; +import { ScryptKeyDerivationParams } from "./derivationParams"; +import { EncryptedData } from "./encryptedData"; +import { Randomness } from "./randomness"; + +interface IRandomness { + id: string; + iv: Buffer; + salt: Buffer; +} + +export enum EncryptorVersion { + V4 = 4, +} + +export class Encryptor { + static encrypt(data: Buffer, password: string, randomness: IRandomness = new Randomness()): EncryptedData { + const kdParams = new ScryptKeyDerivationParams(); + const derivedKey = kdParams.generateDerivedKey(Buffer.from(password), randomness.salt); + const derivedKeyFirstHalf = derivedKey.slice(0, 16); + const derivedKeySecondHalf = derivedKey.slice(16, 32); + const cipher = crypto.createCipheriv(CipherAlgorithm, derivedKeyFirstHalf, randomness.iv); + + const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]); + const mac = crypto.createHmac(DigestAlgorithm, derivedKeySecondHalf).update(ciphertext).digest(); + + return new EncryptedData({ + version: EncryptorVersion.V4, + id: randomness.id, + ciphertext: ciphertext.toString('hex'), + iv: randomness.iv.toString('hex'), + cipher: CipherAlgorithm, + kdf: KeyDerivationFunction, + kdfparams: kdParams, + mac: mac.toString('hex'), + salt: randomness.salt.toString('hex') + }); + } +} diff --git a/src-wallet/crypto/index.ts b/src-wallet/crypto/index.ts new file mode 100644 index 00000000..183a29c0 --- /dev/null +++ b/src-wallet/crypto/index.ts @@ -0,0 +1,7 @@ +export * from "./constants"; +export * from "./encryptor"; +export * from "./decryptor"; +export * from "./pubkeyEncryptor"; +export * from "./pubkeyDecryptor"; +export * from "./encryptedData"; +export * from "./randomness"; diff --git a/src-wallet/crypto/pubkeyDecryptor.ts b/src-wallet/crypto/pubkeyDecryptor.ts new file mode 100644 index 00000000..e9b6d7a0 --- /dev/null +++ b/src-wallet/crypto/pubkeyDecryptor.ts @@ -0,0 +1,36 @@ +import crypto from "crypto"; +import nacl from "tweetnacl"; +import ed2curve from "ed2curve"; +import { X25519EncryptedData } from "./x25519EncryptedData"; +import { UserPublicKey, UserSecretKey } from "../userKeys"; + +export class PubkeyDecryptor { + static decrypt(data: X25519EncryptedData, decryptorSecretKey: UserSecretKey): Buffer { + const ciphertext = Buffer.from(data.ciphertext, 'hex'); + const edhPubKey = Buffer.from(data.identities.ephemeralPubKey, 'hex'); + const originatorPubKeyBuffer = Buffer.from(data.identities.originatorPubKey, 'hex'); + const originatorPubKey = new UserPublicKey(originatorPubKeyBuffer); + + const authMessage = crypto.createHash('sha256').update( + Buffer.concat([ciphertext, edhPubKey]) + ).digest(); + + if (!originatorPubKey.verify(authMessage, Buffer.from(data.mac, 'hex'))) { + throw new Error("Invalid authentication for encrypted message originator"); + } + + const nonce = Buffer.from(data.nonce, 'hex'); + const x25519Secret = ed2curve.convertSecretKey(decryptorSecretKey.valueOf()); + const x25519EdhPubKey = ed2curve.convertPublicKey(edhPubKey); + if (x25519EdhPubKey === null) { + throw new Error("Could not convert ed25519 public key to x25519"); + } + + const decryptedMessage = nacl.box.open(ciphertext, nonce, x25519EdhPubKey, x25519Secret); + if (decryptedMessage === null) { + throw new Error("Failed authentication for given ciphertext"); + } + + return Buffer.from(decryptedMessage); + } +} diff --git a/src-wallet/crypto/pubkeyEncrypt.spec.ts b/src-wallet/crypto/pubkeyEncrypt.spec.ts new file mode 100644 index 00000000..291ca391 --- /dev/null +++ b/src-wallet/crypto/pubkeyEncrypt.spec.ts @@ -0,0 +1,35 @@ +import { assert } from "chai"; +import { loadTestWallet, TestWallet } from "../testutils/wallets"; +import { PubkeyEncryptor } from "./pubkeyEncryptor"; +import { UserPublicKey, UserSecretKey } from "../userKeys"; +import { PubkeyDecryptor } from "./pubkeyDecryptor"; +import { X25519EncryptedData } from "./x25519EncryptedData"; + +describe("test address", () => { + let alice: TestWallet, bob: TestWallet, carol: TestWallet; + const sensitiveData = Buffer.from("alice's secret text for bob"); + let encryptedDataOfAliceForBob: X25519EncryptedData; + + before(async () => { + alice = await loadTestWallet("alice"); + bob = await loadTestWallet("bob"); + carol = await loadTestWallet("carol"); + + encryptedDataOfAliceForBob = PubkeyEncryptor.encrypt(sensitiveData, new UserPublicKey(bob.address.pubkey()), new UserSecretKey(alice.secretKey)); + }); + + it("encrypts/decrypts", () => { + const decryptedData = PubkeyDecryptor.decrypt(encryptedDataOfAliceForBob, new UserSecretKey(bob.secretKey)); + assert.equal(sensitiveData.toString('hex'), decryptedData.toString('hex')); + }); + + it("fails for different originator", () => { + encryptedDataOfAliceForBob.identities.originatorPubKey = carol.address.hex(); + assert.throws(() => PubkeyDecryptor.decrypt(encryptedDataOfAliceForBob, new UserSecretKey(bob.secretKey)), "Invalid authentication for encrypted message originator"); + }); + + it("fails for different DH public key", () => { + encryptedDataOfAliceForBob.identities.ephemeralPubKey = carol.address.hex(); + assert.throws(() => PubkeyDecryptor.decrypt(encryptedDataOfAliceForBob, new UserSecretKey(bob.secretKey)), "Invalid authentication for encrypted message originator"); + }); +}); diff --git a/src-wallet/crypto/pubkeyEncryptor.ts b/src-wallet/crypto/pubkeyEncryptor.ts new file mode 100644 index 00000000..0c89bf16 --- /dev/null +++ b/src-wallet/crypto/pubkeyEncryptor.ts @@ -0,0 +1,47 @@ +import crypto from "crypto"; +import ed2curve from "ed2curve"; +import nacl from "tweetnacl"; +import { UserPublicKey, UserSecretKey } from "../userKeys"; +import { PubKeyEncCipher, PubKeyEncNonceLength, PubKeyEncVersion } from "./constants"; +import { X25519EncryptedData } from "./x25519EncryptedData"; + +export class PubkeyEncryptor { + static encrypt(data: Buffer, recipientPubKey: UserPublicKey, authSecretKey: UserSecretKey): X25519EncryptedData { + // create a new x25519 keypair that will be used for EDH + const edhPair = nacl.sign.keyPair(); + const recipientDHPubKey = ed2curve.convertPublicKey(recipientPubKey.valueOf()); + if (recipientDHPubKey === null) { + throw new Error("Could not convert ed25519 public key to x25519"); + } + const edhConvertedSecretKey = ed2curve.convertSecretKey(edhPair.secretKey); + + // For the nonce we use a random component and a deterministic one based on the message + // - this is so we won't completely rely on the random number generator + const nonceDeterministic = crypto.createHash('sha256').update(data).digest().slice(0, PubKeyEncNonceLength / 2); + const nonceRandom = nacl.randomBytes(PubKeyEncNonceLength / 2); + const nonce = Buffer.concat([nonceDeterministic, nonceRandom]); + const encryptedMessage = nacl.box(data, nonce, recipientDHPubKey, edhConvertedSecretKey); + + // Note that the ciphertext is already authenticated for the ephemeral key - but we want it authenticated by + // the ed25519 key which the user interacts with. A signature over H(ciphertext | edhPubKey) + // would be enough + const authMessage = crypto.createHash('sha256').update( + Buffer.concat([encryptedMessage, edhPair.publicKey]) + ).digest(); + + const signature = authSecretKey.sign(authMessage); + + return new X25519EncryptedData({ + version: PubKeyEncVersion, + nonce: Buffer.from(nonce).toString('hex'), + cipher: PubKeyEncCipher, + ciphertext: Buffer.from(encryptedMessage).toString('hex'), + mac: signature.toString('hex'), + identities: { + recipient: recipientPubKey.hex(), + ephemeralPubKey: Buffer.from(edhPair.publicKey).toString('hex'), + originatorPubKey: authSecretKey.generatePublicKey().hex(), + } + }); + } +} diff --git a/src-wallet/crypto/randomness.ts b/src-wallet/crypto/randomness.ts new file mode 100644 index 00000000..c6355ff8 --- /dev/null +++ b/src-wallet/crypto/randomness.ts @@ -0,0 +1,15 @@ +import { utils } from "@noble/ed25519"; +import { v4 as uuidv4 } from "uuid"; +const crypto = require("crypto"); + +export class Randomness { + salt: Buffer; + iv: Buffer; + id: string; + + constructor(init?: Partial) { + this.salt = init?.salt || Buffer.from(utils.randomBytes(32)); + this.iv = init?.iv || Buffer.from(utils.randomBytes(16)); + this.id = init?.id || uuidv4({ random: crypto.randomBytes(16) }); + } +} diff --git a/src-wallet/crypto/x25519EncryptedData.ts b/src-wallet/crypto/x25519EncryptedData.ts new file mode 100644 index 00000000..4bc3ffbc --- /dev/null +++ b/src-wallet/crypto/x25519EncryptedData.ts @@ -0,0 +1,45 @@ +export class X25519EncryptedData { + nonce: string; + version: number; + cipher: string; + ciphertext: string; + mac: string; + identities: { + recipient: string, + ephemeralPubKey: string, + originatorPubKey: string, + }; + + constructor(data: Omit) { + this.nonce = data.nonce; + this.version = data.version; + this.cipher = data.cipher; + this.ciphertext = data.ciphertext; + this.mac = data.mac; + this.identities = data.identities; + } + + toJSON(): any { + return { + version: this.version, + nonce: this.nonce, + identities: this.identities, + crypto: { + ciphertext: this.ciphertext, + cipher: this.cipher, + mac: this.mac, + } + }; + } + + static fromJSON(data: any): X25519EncryptedData { + return new X25519EncryptedData({ + nonce: data.nonce, + version: data.version, + ciphertext: data.crypto.ciphertext, + cipher: data.crypto.cipher, + mac: data.crypto.mac, + identities: data.identities, + }); + } +} diff --git a/src-wallet/errors.ts b/src-wallet/errors.ts new file mode 100644 index 00000000..4c3ea2ec --- /dev/null +++ b/src-wallet/errors.ts @@ -0,0 +1,65 @@ +/** + * The base class for exceptions (errors). + */ +export class Err extends Error { + inner: Error | undefined = undefined; + + public constructor(message: string, inner?: Error) { + super(message); + this.inner = inner; + } +} + +/** + * Signals that an invariant failed. + */ +export class ErrInvariantFailed extends Err { + public constructor(message: string) { + super(`"Invariant failed: ${message}`); + } +} + +/** + * Signals a wrong mnemonic format. + */ +export class ErrWrongMnemonic extends Err { + public constructor() { + super("Wrong mnemonic format"); + } +} + +/** + * Signals a bad mnemonic entropy. + */ +export class ErrBadMnemonicEntropy extends Err { + public constructor(inner: Error) { + super("Bad mnemonic entropy", inner); + } +} + +/** + * Signals a bad PEM file. + */ +export class ErrBadPEM extends Err { + public constructor(message?: string) { + super(message ? `Bad PEM: ${message}` : `Bad PEM`); + } +} + +/** + * Signals an error related to signing a message (a transaction). + */ +export class ErrSignerCannotSign extends Err { + public constructor(inner: Error) { + super(`Cannot sign`, inner); + } +} + +/** + * Signals a bad address. + */ +export class ErrBadAddress extends Err { + public constructor(value: string, inner?: Error) { + super(`Bad address: ${value}`, inner); + } +} diff --git a/src-wallet/index.ts b/src-wallet/index.ts new file mode 100644 index 00000000..af9ecec6 --- /dev/null +++ b/src-wallet/index.ts @@ -0,0 +1,8 @@ +export * from "./mnemonic"; +export * from "./pem"; +export * from "./userKeys"; +export * from "./userSigner"; +export * from "./userVerifier"; +export * from "./userWallet"; +export * from "./validatorKeys"; +export * from "./validatorSigner"; diff --git a/src-wallet/mnemonic.ts b/src-wallet/mnemonic.ts new file mode 100644 index 00000000..93b62ee3 --- /dev/null +++ b/src-wallet/mnemonic.ts @@ -0,0 +1,65 @@ +import { entropyToMnemonic, generateMnemonic, mnemonicToEntropy, mnemonicToSeedSync, validateMnemonic } from "bip39"; +import { derivePath } from "ed25519-hd-key"; +import { ErrBadMnemonicEntropy, ErrWrongMnemonic } from "./errors"; +import { UserSecretKey } from "./userKeys"; + +const MNEMONIC_STRENGTH = 256; +const BIP44_DERIVATION_PREFIX = "m/44'/508'/0'/0'"; + +export class Mnemonic { + private readonly text: string; + + private constructor(text: string) { + this.text = text; + } + + static generate(): Mnemonic { + const text = generateMnemonic(MNEMONIC_STRENGTH); + return new Mnemonic(text); + } + + static fromString(text: string) { + text = text.trim(); + + Mnemonic.assertTextIsValid(text); + return new Mnemonic(text); + } + + static fromEntropy(entropy: Uint8Array): Mnemonic { + try { + const text = entropyToMnemonic(Buffer.from(entropy)); + return new Mnemonic(text); + } catch (err: any) { + throw new ErrBadMnemonicEntropy(err); + } + } + + public static assertTextIsValid(text: string) { + let isValid = validateMnemonic(text); + + if (!isValid) { + throw new ErrWrongMnemonic(); + } + } + + deriveKey(addressIndex: number = 0, password: string = ""): UserSecretKey { + let seed = mnemonicToSeedSync(this.text, password); + let derivationPath = `${BIP44_DERIVATION_PREFIX}/${addressIndex}'`; + let derivationResult = derivePath(derivationPath, seed.toString("hex")); + let key = derivationResult.key; + return new UserSecretKey(key); + } + + getWords(): string[] { + return this.text.split(" "); + } + + getEntropy(): Uint8Array { + const entropy = mnemonicToEntropy(this.text); + return Buffer.from(entropy, "hex"); + } + + toString(): string { + return this.text; + } +} diff --git a/src-wallet/pem.spec.ts b/src-wallet/pem.spec.ts new file mode 100644 index 00000000..4861b230 --- /dev/null +++ b/src-wallet/pem.spec.ts @@ -0,0 +1,96 @@ +import { assert } from "chai"; +import { parse, parseUserKey, parseValidatorKey } from "./pem"; +import { Buffer } from "buffer"; +import { BLS } from "./validatorKeys"; +import { ErrBadPEM } from "./errors"; +import { loadTestWallet, TestWallet } from "./testutils/wallets"; + +describe("test PEMs", () => { + let alice: TestWallet, bob: TestWallet, carol: TestWallet; + + before(async function () { + alice = await loadTestWallet("alice"); + bob = await loadTestWallet("bob"); + carol = await loadTestWallet("carol"); + }); + + it("should parseUserKey", () => { + let aliceKey = parseUserKey(alice.pemFileText); + + assert.equal(aliceKey.hex(), alice.secretKeyHex); + assert.equal(aliceKey.generatePublicKey().toAddress().bech32(), alice.address.bech32()); + }); + + it("should parseValidatorKey", async () => { + await BLS.initIfNecessary(); + + let pem = `-----BEGIN PRIVATE KEY for e7beaa95b3877f47348df4dd1cb578a4f7cabf7a20bfeefe5cdd263878ff132b765e04fef6f40c93512b666c47ed7719b8902f6c922c04247989b7137e837cc81a62e54712471c97a2ddab75aa9c2f58f813ed4c0fa722bde0ab718bff382208----- +N2NmZjk5YmQ2NzE1MDJkYjdkMTViYzhhYmMwYzlhODA0ZmI5MjU0MDZmYmRkNTBm +MWU0YzE3YTRjZDc3NDI0Nw== +-----END PRIVATE KEY for e7beaa95b3877f47348df4dd1cb578a4f7cabf7a20bfeefe5cdd263878ff132b765e04fef6f40c93512b666c47ed7719b8902f6c922c04247989b7137e837cc81a62e54712471c97a2ddab75aa9c2f58f813ed4c0fa722bde0ab718bff382208-----`; + + let validatorKey = parseValidatorKey(pem); + + assert.equal(validatorKey.hex(), "7cff99bd671502db7d15bc8abc0c9a804fb925406fbdd50f1e4c17a4cd774247"); + assert.equal(validatorKey.generatePublicKey().hex(), "e7beaa95b3877f47348df4dd1cb578a4f7cabf7a20bfeefe5cdd263878ff132b765e04fef6f40c93512b666c47ed7719b8902f6c922c04247989b7137e837cc81a62e54712471c97a2ddab75aa9c2f58f813ed4c0fa722bde0ab718bff382208"); + }); + + it("should parse multi-key PEM files", () => { + // The user PEM files encode both the seed and the pubkey in their payloads. + let payloadAlice = Buffer.from(alice.secretKeyHex + alice.address.hex()).toString("base64"); + let payloadBob = Buffer.from(bob.secretKeyHex + bob.address.hex()).toString("base64"); + let payloadCarol = Buffer.from(carol.secretKeyHex + carol.address.hex()).toString("base64"); + + let expected = [ + Buffer.concat([alice.secretKey, alice.address.pubkey()]), + Buffer.concat([bob.secretKey, bob.address.pubkey()]), + Buffer.concat([carol.secretKey, carol.address.pubkey()]) + ]; + + let trivialContent = `-----BEGIN PRIVATE KEY for alice +${payloadAlice} +-----END PRIVATE KEY for alice +-----BEGIN PRIVATE KEY for bob +${payloadBob} +-----END PRIVATE KEY for bob +-----BEGIN PRIVATE KEY for carol +${payloadCarol} +-----END PRIVATE KEY for carol +`; + + assert.deepEqual(parse(trivialContent, 64), expected); + + let contentWithWhitespaces = ` +-----BEGIN PRIVATE KEY for alice + ${payloadAlice} + -----END PRIVATE KEY for alice + + -----BEGIN PRIVATE KEY for bob + ${payloadBob} + -----END PRIVATE KEY for bob + -----BEGIN PRIVATE KEY for carol + + + ${payloadCarol} + -----END PRIVATE KEY for carol + `; + + assert.deepEqual(parse(contentWithWhitespaces, 64), expected); + }); + + it("should report parsing errors", () => { + let contentWithoutEnd = `-----BEGIN PRIVATE KEY for alice + NDEzZjQyNTc1ZjdmMjZmYWQzMzE3YTc3ODc3MTIxMmZkYjgwMjQ1ODUwOTgxZTQ4 + YjU4YTRmMjVlMzQ0ZThmOTAxMzk0NzJlZmY2ODg2NzcxYTk4MmYzMDgzZGE1ZDQy + MWYyNGMyOTE4MWU2Mzg4ODIyOGRjODFjYTYwZDY5ZTE=`; + + assert.throw(() => parseUserKey(contentWithoutEnd), ErrBadPEM); + + let contentWithBadData = `-----BEGIN PRIVATE KEY for alice + NDEzZjQyNTc1ZjdmMjZmYWQzMzE3YTc3ODc3MTIxMmZkYjgwMjQ1ODUwOTgxZTQ4 + YjU4YTRmMjVlMzQ0ZThmOTAxMzk0NzJlZmY2ODg2NzcxYTk4MmYzMDgzZGE1Zfoo + -----END PRIVATE KEY for alice`; + + assert.throw(() => parseUserKey(contentWithBadData), ErrBadPEM); + }); +}); diff --git a/src-wallet/pem.ts b/src-wallet/pem.ts new file mode 100644 index 00000000..f9add549 --- /dev/null +++ b/src-wallet/pem.ts @@ -0,0 +1,56 @@ +import { ErrBadPEM } from "./errors"; +import { UserSecretKey, USER_PUBKEY_LENGTH, USER_SEED_LENGTH } from "./userKeys"; +import { ValidatorSecretKey, VALIDATOR_SECRETKEY_LENGTH } from "./validatorKeys"; + +export function parseUserKey(text: string, index: number = 0): UserSecretKey { + let keys = parseUserKeys(text); + return keys[index]; +} + +export function parseUserKeys(text: string): UserSecretKey[] { + // The user PEM files encode both the seed and the pubkey in their payloads. + let buffers = parse(text, USER_SEED_LENGTH + USER_PUBKEY_LENGTH); + return buffers.map(buffer => new UserSecretKey(buffer.slice(0, USER_SEED_LENGTH))); +} + +export function parseValidatorKey(text: string, index: number = 0): ValidatorSecretKey { + let keys = parseValidatorKeys(text); + return keys[index]; +} + +export function parseValidatorKeys(text: string): ValidatorSecretKey[] { + let buffers = parse(text, VALIDATOR_SECRETKEY_LENGTH); + return buffers.map(buffer => new ValidatorSecretKey(buffer)); +} + +export function parse(text: string, expectedLength: number): Buffer[] { + // Split by newlines, trim whitespace, then discard remaining empty lines. + let lines = text.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0); + let buffers: Buffer[] = []; + let linesAccumulator: string[] = []; + + for (const line of lines) { + if (line.startsWith("-----BEGIN")) { + linesAccumulator = []; + } else if (line.startsWith("-----END")) { + let asBase64 = linesAccumulator.join(""); + let asHex = Buffer.from(asBase64, "base64").toString(); + let asBytes = Buffer.from(asHex, "hex"); + + if (asBytes.length != expectedLength) { + throw new ErrBadPEM(`incorrect key length: expected ${expectedLength}, found ${asBytes.length}`); + } + + buffers.push(asBytes); + linesAccumulator = []; + } else { + linesAccumulator.push(line); + } + } + + if (linesAccumulator.length != 0) { + throw new ErrBadPEM("incorrect file structure"); + } + + return buffers; +} diff --git a/src-wallet/signature.ts b/src-wallet/signature.ts new file mode 100644 index 00000000..a99620ed --- /dev/null +++ b/src-wallet/signature.ts @@ -0,0 +1,14 @@ +/** + * Signature, as an immutable object. + */ +export class Signature { + private readonly buffer: Buffer; + + constructor(buffer: Buffer | Uint8Array) { + this.buffer = Buffer.from(buffer); + } + + hex() { + return this.buffer.toString("hex"); + } +} diff --git a/src-wallet/testdata/alice.json b/src-wallet/testdata/alice.json new file mode 100644 index 00000000..18c4ea1e --- /dev/null +++ b/src-wallet/testdata/alice.json @@ -0,0 +1,23 @@ +{ + "version": 4, + "kind": "secretKey", + "id": "0dc10c02-b59b-4bac-9710-6b2cfa4284ba", + "address": "0139472eff6886771a982f3083da5d421f24c29181e63888228dc81ca60d69e1", + "bech32": "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th", + "crypto": { + "ciphertext": "4c41ef6fdfd52c39b1585a875eb3c86d30a315642d0e35bb8205b6372c1882f135441099b11ff76345a6f3a930b5665aaf9f7325a32c8ccd60081c797aa2d538", + "cipherparams": { + "iv": "033182afaa1ebaafcde9ccc68a5eac31" + }, + "cipher": "aes-128-ctr", + "kdf": "scrypt", + "kdfparams": { + "dklen": 32, + "salt": "4903bd0e7880baa04fc4f886518ac5c672cdc745a6bd13dcec2b6c12e9bffe8d", + "n": 4096, + "r": 8, + "p": 1 + }, + "mac": "5b4a6f14ab74ba7ca23db6847e28447f0e6a7724ba9664cf425df707a84f5a8b" + } +} diff --git a/src-wallet/testdata/alice.pem b/src-wallet/testdata/alice.pem new file mode 100644 index 00000000..d27bb68b --- /dev/null +++ b/src-wallet/testdata/alice.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY for erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th----- +NDEzZjQyNTc1ZjdmMjZmYWQzMzE3YTc3ODc3MTIxMmZkYjgwMjQ1ODUwOTgxZTQ4 +YjU4YTRmMjVlMzQ0ZThmOTAxMzk0NzJlZmY2ODg2NzcxYTk4MmYzMDgzZGE1ZDQy +MWYyNGMyOTE4MWU2Mzg4ODIyOGRjODFjYTYwZDY5ZTE= +-----END PRIVATE KEY for erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th----- \ No newline at end of file diff --git a/src-wallet/testdata/bob.json b/src-wallet/testdata/bob.json new file mode 100644 index 00000000..9efb4110 --- /dev/null +++ b/src-wallet/testdata/bob.json @@ -0,0 +1,23 @@ +{ + "version": 4, + "kind": "secretKey", + "id": "85fdc8a7-7119-479d-b7fb-ab4413ed038d", + "address": "8049d639e5a6980d1cd2392abcce41029cda74a1563523a202f09641cc2618f8", + "bech32": "erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx", + "crypto": { + "ciphertext": "c2664a31350aaf6a00525560db75c254d0aea65dc466441356c1dd59253cceb9e83eb05730ef3f42a11573c9a0e33dd952d488f00535b35357bb41d127b1eb82", + "cipherparams": { + "iv": "18378411e31f6c4e99f1435d9ab82831" + }, + "cipher": "aes-128-ctr", + "kdf": "scrypt", + "kdfparams": { + "dklen": 32, + "salt": "18304455ac2dbe2a2018bda162bd03ef95b81622e99d8275c34a6d5e6932a68b", + "n": 4096, + "r": 8, + "p": 1 + }, + "mac": "23756172195ac483fa29025dc331bc7aa2c139533922a8dc08642eb0a677541f" + } +} diff --git a/src-wallet/testdata/bob.pem b/src-wallet/testdata/bob.pem new file mode 100644 index 00000000..00b5bc4e --- /dev/null +++ b/src-wallet/testdata/bob.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY for erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx----- +YjhjYTZmODIwM2ZiNGI1NDVhOGU4M2M1Mzg0ZGEwMzNjNDE1ZGIxNTViNTNmYjVi +OGViYTdmZjVhMDM5ZDYzOTgwNDlkNjM5ZTVhNjk4MGQxY2QyMzkyYWJjY2U0MTAy +OWNkYTc0YTE1NjM1MjNhMjAyZjA5NjQxY2MyNjE4Zjg= +-----END PRIVATE KEY for erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx----- \ No newline at end of file diff --git a/src-wallet/testdata/carol.json b/src-wallet/testdata/carol.json new file mode 100644 index 00000000..1014a823 --- /dev/null +++ b/src-wallet/testdata/carol.json @@ -0,0 +1,23 @@ +{ + "version": 4, + "kind": "secretKey", + "id": "65894f35-d142-41d2-9335-6ad02e0ed0be", + "address": "b2a11555ce521e4944e09ab17549d85b487dcd26c84b5017a39e31a3670889ba", + "bech32": "erd1k2s324ww2g0yj38qn2ch2jwctdy8mnfxep94q9arncc6xecg3xaq6mjse8", + "crypto": { + "ciphertext": "bdfb984a1e7c7460f0a289749609730cdc99d7ce85b59305417c2c0f007b2a6aaa7203dd94dbf27315bced39b0b281769fbc70b01e6e57f89ae2f2a9e9100007", + "cipherparams": { + "iv": "258ed2b4dc506b4dc9d274b0449b0eb0" + }, + "cipher": "aes-128-ctr", + "kdf": "scrypt", + "kdfparams": { + "dklen": 32, + "salt": "4f2f5530ce28dc0210962589b908f52714f75c8fb79ff18bdd0024c43c7a220b", + "n": 4096, + "r": 8, + "p": 1 + }, + "mac": "f8de52e2627024eaa33f2ee5eadcd3d3815e10dd274ea966dc083d000cc8b258" + } +} diff --git a/src-wallet/testdata/carol.pem b/src-wallet/testdata/carol.pem new file mode 100644 index 00000000..5551c9c0 --- /dev/null +++ b/src-wallet/testdata/carol.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY for erd1k2s324ww2g0yj38qn2ch2jwctdy8mnfxep94q9arncc6xecg3xaq6mjse8----- +ZTI1M2E1NzFjYTE1M2RjMmFlZTg0NTgxOWY3NGJjYzk3NzNiMDU4NmVkZWFkMTVh +OTRjYjcyMzVhNTAyNzQzNmIyYTExNTU1Y2U1MjFlNDk0NGUwOWFiMTc1NDlkODVi +NDg3ZGNkMjZjODRiNTAxN2EzOWUzMWEzNjcwODg5YmE= +-----END PRIVATE KEY for erd1k2s324ww2g0yj38qn2ch2jwctdy8mnfxep94q9arncc6xecg3xaq6mjse8----- \ No newline at end of file diff --git a/src-wallet/testdata/withDummyMnemonic.json b/src-wallet/testdata/withDummyMnemonic.json new file mode 100644 index 00000000..29a58529 --- /dev/null +++ b/src-wallet/testdata/withDummyMnemonic.json @@ -0,0 +1,21 @@ +{ + "version": 4, + "id": "5b448dbc-5c72-4d83-8038-938b1f8dff19", + "kind": "mnemonic", + "crypto": { + "ciphertext": "6d70fbdceba874f56f15af4b1d060223799288cfc5d276d9ebb91732f5a38c3c59f83896fa7e7eb6a04c05475a6fe4d154de9b9441864c507abd0eb6987dac521b64c0c82783a3cd1e09270cd6cb5ae493f9af694b891253ac1f1ffded68b5ef39c972307e3c33a8354337540908acc795d4df72298dda1ca28ac920983e6a39a01e2bc988bd0b21f864c6de8b5356d11e4b77bc6f75ef", + "cipherparams": { + "iv": "2da5620906634972d9a623bc249d63d4" + }, + "cipher": "aes-128-ctr", + "kdf": "scrypt", + "kdfparams": { + "dklen": 32, + "salt": "aa9e0ba6b188703071a582c10e5331f2756279feb0e2768f1ba0fd38ec77f035", + "n": 4096, + "r": 8, + "p": 1 + }, + "mac": "5bc1b20b6d903b8ef3273eedf028112d65eaf85a5ef4215917c1209ec2df715a" + } +} diff --git a/src-wallet/testdata/withDummySecretKey.json b/src-wallet/testdata/withDummySecretKey.json new file mode 100644 index 00000000..cfba7552 --- /dev/null +++ b/src-wallet/testdata/withDummySecretKey.json @@ -0,0 +1,23 @@ +{ + "version": 4, + "kind": "secretKey", + "id": "c1d4b111-b8d2-4916-a213-bcfd237edd29", + "address": "0139472eff6886771a982f3083da5d421f24c29181e63888228dc81ca60d69e1", + "bech32": "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th", + "crypto": { + "ciphertext": "75fbe213fc1964ce03100cf7d873748edf83a02631c8af9abdb23d210b9a2a15940bea2e56718f7bd710a938df5eb424c629e6a39b6ee056ed80d6e5f3b97791", + "cipherparams": { + "iv": "226d13be12373603af2b4edefcaa436f" + }, + "cipher": "aes-128-ctr", + "kdf": "scrypt", + "kdfparams": { + "dklen": 32, + "salt": "d57862c212bac142a89da97fb9bf9f5c91c8e8ddba952262dafe928e1c8a9906", + "n": 4096, + "r": 8, + "p": 1 + }, + "mac": "5bab92263237c5d595f565622dd2e61ea3dfd43580cecda7fd2f42d469b42e7f" + } +} diff --git a/src-wallet/testdata/withoutKind.json b/src-wallet/testdata/withoutKind.json new file mode 100644 index 00000000..9e83170c --- /dev/null +++ b/src-wallet/testdata/withoutKind.json @@ -0,0 +1,22 @@ +{ + "version": 4, + "id": "0dc10c02-b59b-4bac-9710-6b2cfa4284ba", + "address": "0139472eff6886771a982f3083da5d421f24c29181e63888228dc81ca60d69e1", + "bech32": "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th", + "crypto": { + "ciphertext": "4c41ef6fdfd52c39b1585a875eb3c86d30a315642d0e35bb8205b6372c1882f135441099b11ff76345a6f3a930b5665aaf9f7325a32c8ccd60081c797aa2d538", + "cipherparams": { + "iv": "033182afaa1ebaafcde9ccc68a5eac31" + }, + "cipher": "aes-128-ctr", + "kdf": "scrypt", + "kdfparams": { + "dklen": 32, + "salt": "4903bd0e7880baa04fc4f886518ac5c672cdc745a6bd13dcec2b6c12e9bffe8d", + "n": 4096, + "r": 8, + "p": 1 + }, + "mac": "5b4a6f14ab74ba7ca23db6847e28447f0e6a7724ba9664cf425df707a84f5a8b" + } +} diff --git a/src-wallet/testutils/files.ts b/src-wallet/testutils/files.ts new file mode 100644 index 00000000..51863430 --- /dev/null +++ b/src-wallet/testutils/files.ts @@ -0,0 +1,27 @@ +import * as fs from "fs"; + +export async function readTestFile(filePath: string): Promise { + if (isOnBrowserTests()) { + return await downloadTextFile(filePath); + } + + return await fs.promises.readFile(filePath, { encoding: "utf8" }); +} + +export function isOnBrowserTests() { + const BROWSER_TESTS_URL = "browser-tests"; + + let noWindow = typeof window === "undefined"; + if (noWindow) { + return false; + } + + let isOnTests = window.location.href.includes(BROWSER_TESTS_URL); + return isOnTests; +} + +export async function downloadTextFile(url: string) { + const response = await fetch(url); + const text = await response.text(); + return text; +} diff --git a/src-wallet/testutils/message.ts b/src-wallet/testutils/message.ts new file mode 100644 index 00000000..c115d20d --- /dev/null +++ b/src-wallet/testutils/message.ts @@ -0,0 +1,21 @@ +/** + * A dummy message used in tests. + */ +export class TestMessage { + foo: string = ""; + bar: string = ""; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + serializeForSigning(): Buffer { + let plainObject = { + foo: this.foo, + bar: this.bar + }; + + let serialized = JSON.stringify(plainObject); + return Buffer.from(serialized); + } +} diff --git a/src-wallet/testutils/transaction.ts b/src-wallet/testutils/transaction.ts new file mode 100644 index 00000000..318b9a8b --- /dev/null +++ b/src-wallet/testutils/transaction.ts @@ -0,0 +1,43 @@ +/** + * A dummy transaction used in tests. + */ +export class TestTransaction { + nonce: number = 0; + value: string = ""; + receiver: string = ""; + sender: string = ""; + guardian: string = ""; + gasPrice: number = 0; + gasLimit: number = 0; + data: string = ""; + chainID: string = ""; + version: number = 1; + options: number = 0; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + serializeForSigning(): Buffer { + const dataEncoded = this.data ? Buffer.from(this.data).toString("base64") : undefined; + const guardian = this.guardian ? this.guardian : undefined; + const options = this.options ? this.options : undefined; + + const plainObject = { + nonce: this.nonce, + value: this.value, + receiver: this.receiver, + sender: this.sender, + guardian: guardian, + gasPrice: this.gasPrice, + gasLimit: this.gasLimit, + data: dataEncoded, + chainID: this.chainID, + options: options, + version: this.version + }; + + const serialized = JSON.stringify(plainObject); + return Buffer.from(serialized); + } +} diff --git a/src-wallet/testutils/wallets.ts b/src-wallet/testutils/wallets.ts new file mode 100644 index 00000000..1ce1af3e --- /dev/null +++ b/src-wallet/testutils/wallets.ts @@ -0,0 +1,46 @@ +import * as path from "path"; +import { UserAddress } from "../userAddress"; +import { UserSecretKey } from "../userKeys"; +import { readTestFile } from "./files"; + +export const DummyPassword = "password"; +export const DummyMnemonic = "moral volcano peasant pass circle pen over picture flat shop clap goat never lyrics gather prepare woman film husband gravity behind test tiger improve"; +export const DummyMnemonicOf12Words = "matter trumpet twenty parade fame north lift sail valve salon foster cinnamon"; + +export async function loadTestWallet(name: string): Promise { + const keystore = await loadTestKeystore(`${name}.json`) + const pemText = await loadTestPemFile(`${name}.pem`) + const pemKey = UserSecretKey.fromPem(pemText); + const address = new UserAddress(Buffer.from(keystore.address, "hex")); + + return new TestWallet(address, pemKey.hex(), keystore, pemText); +} + +export async function loadTestKeystore(file: string): Promise { + const testdataPath = path.resolve(__dirname, "..", "testdata"); + const keystorePath = path.resolve(testdataPath, file); + const json = await readTestFile(keystorePath); + return JSON.parse(json); +} + +export async function loadTestPemFile(file: string): Promise { + const testdataPath = path.resolve(__dirname, "..", "testdata"); + const pemFilePath = path.resolve(testdataPath, file); + return await readTestFile(pemFilePath); +} + +export class TestWallet { + readonly address: UserAddress; + readonly secretKeyHex: string; + readonly secretKey: Buffer; + readonly keyFileObject: any; + readonly pemFileText: any; + + constructor(address: UserAddress, secretKeyHex: string, keyFileObject: any, pemFileText: any) { + this.address = address; + this.secretKeyHex = secretKeyHex; + this.secretKey = Buffer.from(secretKeyHex, "hex"); + this.keyFileObject = keyFileObject; + this.pemFileText = pemFileText; + } +} diff --git a/src-wallet/userAddress.ts b/src-wallet/userAddress.ts new file mode 100644 index 00000000..b7a885cf --- /dev/null +++ b/src-wallet/userAddress.ts @@ -0,0 +1,96 @@ +import * as bech32 from "bech32"; +import { LibraryConfig } from "./config"; +import { ErrBadAddress } from "./errors"; + +/** + * @internal + * For internal use only. + */ +export class UserAddress { + private readonly buffer: Buffer; + private readonly hrp: string; + + public constructor(buffer: Buffer, hrp?: string) { + this.buffer = buffer; + this.hrp = hrp || LibraryConfig.DefaultAddressHrp; + } + + static newFromBech32(value: string): UserAddress { + const { hrp, pubkey } = decodeFromBech32({ value, allowCustomHrp: true }); + return new UserAddress(pubkey, hrp); + } + + /** + * @internal + * @deprecated + */ + static fromBech32(value: string): UserAddress { + // On this legacy flow, we do not accept addresses with custom hrp (in order to avoid behavioral breaking changes). + const { hrp, pubkey } = decodeFromBech32({ value, allowCustomHrp: false }); + return new UserAddress(pubkey, hrp); + } + + /** + * Returns the hex representation of the address (pubkey) + */ + hex(): string { + return this.buffer.toString("hex"); + } + + /** + * Returns the bech32 representation of the address + */ + bech32(): string { + const words = bech32.toWords(this.pubkey()); + const address = bech32.encode(this.hrp, words); + return address; + } + + /** + * Returns the pubkey as raw bytes (buffer) + */ + pubkey(): Buffer { + return this.buffer; + } + + /** + * Returns the bech32 representation of the address + */ + toString(): string { + return this.bech32(); + } + + /** + * Converts the address to a pretty, plain JavaScript object. + */ + toJSON(): object { + return { + bech32: this.bech32(), + pubkey: this.hex() + }; + } +} + +function decodeFromBech32(options: { value: string; allowCustomHrp: boolean }): { hrp: string; pubkey: Buffer } { + const value = options.value; + const allowCustomHrp = options.allowCustomHrp; + + let hrp: string; + let pubkey: Buffer; + + try { + const decoded = bech32.decode(value); + + hrp = decoded.prefix; + pubkey = Buffer.from(bech32.fromWords(decoded.words)); + } catch (err: any) { + throw new ErrBadAddress(value, err); + } + + // Workaround, in order to avoid behavioral breaking changes on legacy flows. + if (!allowCustomHrp && hrp != LibraryConfig.DefaultAddressHrp) { + throw new ErrBadAddress(value); + } + + return { hrp, pubkey }; +} diff --git a/src-wallet/userKeys.ts b/src-wallet/userKeys.ts new file mode 100644 index 00000000..6acb76f0 --- /dev/null +++ b/src-wallet/userKeys.ts @@ -0,0 +1,83 @@ +import * as ed from "@noble/ed25519"; +import { sha512 } from "@noble/hashes/sha512"; +import { guardLength } from "./assertions"; +import { parseUserKey } from "./pem"; +import { UserAddress } from "./userAddress"; + +export const USER_SEED_LENGTH = 32; +export const USER_PUBKEY_LENGTH = 32; + +// See: https://github.com/paulmillr/noble-ed25519 +// In a future version of sdk-wallet, we'll switch to using the async functions of noble-ed25519. +ed.utils.sha512Sync = (...m) => sha512(ed.utils.concatBytes(...m)); + +export class UserSecretKey { + private readonly buffer: Buffer; + + constructor(buffer: Uint8Array) { + guardLength(buffer, USER_SEED_LENGTH); + + this.buffer = Buffer.from(buffer); + } + + static fromString(value: string): UserSecretKey { + guardLength(value, USER_SEED_LENGTH * 2); + + const buffer = Buffer.from(value, "hex"); + return new UserSecretKey(buffer); + } + + static fromPem(text: string, index: number = 0): UserSecretKey { + return parseUserKey(text, index); + } + + generatePublicKey(): UserPublicKey { + const buffer = ed.sync.getPublicKey(new Uint8Array(this.buffer)); + return new UserPublicKey(buffer); + } + + sign(message: Buffer | Uint8Array): Buffer { + const signature = ed.sync.sign(new Uint8Array(message), new Uint8Array(this.buffer)); + return Buffer.from(signature); + } + + hex(): string { + return this.buffer.toString("hex"); + } + + valueOf(): Buffer { + return this.buffer; + } +} + +export class UserPublicKey { + private readonly buffer: Buffer; + + constructor(buffer: Uint8Array) { + guardLength(buffer, USER_PUBKEY_LENGTH); + + this.buffer = Buffer.from(buffer); + } + + verify(data: Buffer | Uint8Array, signature: Buffer | Uint8Array): boolean { + try { + const ok = ed.sync.verify(new Uint8Array(signature), new Uint8Array(data), new Uint8Array(this.buffer)); + return ok; + } catch (err: any) { + console.error(err); + return false; + } + } + + hex(): string { + return this.buffer.toString("hex"); + } + + toAddress(hrp?: string): UserAddress { + return new UserAddress(this.buffer, hrp); + } + + valueOf(): Buffer { + return this.buffer; + } +} diff --git a/src-wallet/userSigner.ts b/src-wallet/userSigner.ts new file mode 100644 index 00000000..b78d5048 --- /dev/null +++ b/src-wallet/userSigner.ts @@ -0,0 +1,51 @@ +import { ErrSignerCannotSign } from "./errors"; +import { UserAddress } from "./userAddress"; +import { UserSecretKey } from "./userKeys"; +import { UserWallet } from "./userWallet"; + +interface IUserSecretKey { + sign(message: Buffer | Uint8Array): Buffer; + generatePublicKey(): IUserPublicKey; +} + +interface IUserPublicKey { + toAddress(hrp?: string): { bech32(): string; }; +} + +/** + * ed25519 signer + */ +export class UserSigner { + protected readonly secretKey: IUserSecretKey; + + constructor(secretKey: IUserSecretKey) { + this.secretKey = secretKey; + } + + static fromWallet(keyFileObject: any, password: string, addressIndex?: number): UserSigner { + const secretKey = UserWallet.decrypt(keyFileObject, password, addressIndex); + return new UserSigner(secretKey); + } + + static fromPem(text: string, index: number = 0) { + let secretKey = UserSecretKey.fromPem(text, index); + return new UserSigner(secretKey); + } + + async sign(data: Buffer | Uint8Array): Promise { + try { + const signature = this.secretKey.sign(data); + return signature; + } catch (err: any) { + throw new ErrSignerCannotSign(err); + } + } + + /** + * Gets the address of the signer. + */ + getAddress(hrp?: string): UserAddress { + const bech32 = this.secretKey.generatePublicKey().toAddress(hrp).bech32(); + return UserAddress.newFromBech32(bech32); + } +} diff --git a/src-wallet/userVerifier.ts b/src-wallet/userVerifier.ts new file mode 100644 index 00000000..ca56585a --- /dev/null +++ b/src-wallet/userVerifier.ts @@ -0,0 +1,31 @@ +import { UserPublicKey } from "./userKeys"; + +interface IAddress { + pubkey(): Buffer; +} + +/** + * ed25519 signature verification + */ +export class UserVerifier { + publicKey: UserPublicKey; + + constructor(publicKey: UserPublicKey) { + this.publicKey = publicKey; + } + + static fromAddress(address: IAddress): UserVerifier { + let publicKey = new UserPublicKey(address.pubkey()); + return new UserVerifier(publicKey); + } + + /** + * + * @param data the raw data to be verified (e.g. an already-serialized enveloped message) + * @param signature the signature to be verified + * @returns true if the signature is valid, false otherwise + */ + verify(data: Buffer | Uint8Array, signature: Buffer | Uint8Array): boolean { + return this.publicKey.verify(data, signature); + } +} diff --git a/src-wallet/userWallet.ts b/src-wallet/userWallet.ts new file mode 100644 index 00000000..8c7ff833 --- /dev/null +++ b/src-wallet/userWallet.ts @@ -0,0 +1,206 @@ +import { CipherAlgorithm, Decryptor, EncryptedData, Encryptor, KeyDerivationFunction, Randomness } from "./crypto"; +import { ScryptKeyDerivationParams } from "./crypto/derivationParams"; +import { Err } from "./errors"; +import { Mnemonic } from "./mnemonic"; +import { UserPublicKey, UserSecretKey } from "./userKeys"; + +interface IRandomness { + id: string; + iv: Buffer; + salt: Buffer; +} + +export enum UserWalletKind { + SecretKey = "secretKey", + Mnemonic = "mnemonic" +} + +export class UserWallet { + private readonly kind: UserWalletKind; + private readonly encryptedData: EncryptedData; + private readonly publicKeyWhenKindIsSecretKey?: UserPublicKey; + + private constructor({ kind, encryptedData, publicKeyWhenKindIsSecretKey }: { + kind: UserWalletKind; + encryptedData: EncryptedData; + publicKeyWhenKindIsSecretKey?: UserPublicKey; + }) { + this.kind = kind; + this.encryptedData = encryptedData; + this.publicKeyWhenKindIsSecretKey = publicKeyWhenKindIsSecretKey; + } + + static fromSecretKey({ secretKey, password, randomness }: { + secretKey: UserSecretKey; + password: string; + randomness?: IRandomness; + }): UserWallet { + randomness = randomness || new Randomness(); + + const publicKey = secretKey.generatePublicKey(); + const data = Buffer.concat([secretKey.valueOf(), publicKey.valueOf()]); + const encryptedData = Encryptor.encrypt(data, password, randomness); + + return new UserWallet({ + kind: UserWalletKind.SecretKey, + encryptedData, + publicKeyWhenKindIsSecretKey: publicKey + }); + } + + static fromMnemonic({ mnemonic, password, randomness }: { + mnemonic: string; + password: string; + randomness?: IRandomness; + }): UserWallet { + randomness = randomness || new Randomness(); + + Mnemonic.assertTextIsValid(mnemonic); + const data = Buffer.from(mnemonic); + const encryptedData = Encryptor.encrypt(data, password, randomness); + + return new UserWallet({ + kind: UserWalletKind.Mnemonic, + encryptedData + }); + } + + static decrypt(keyFileObject: any, password: string, addressIndex?: number): UserSecretKey { + const kind = keyFileObject.kind || UserWalletKind.SecretKey; + + if (kind == UserWalletKind.SecretKey) { + if (addressIndex !== undefined) { + throw new Err("addressIndex must not be provided when kind == 'secretKey'"); + } + + return UserWallet.decryptSecretKey(keyFileObject, password); + } + + if (kind == UserWalletKind.Mnemonic) { + const mnemonic = this.decryptMnemonic(keyFileObject, password); + return mnemonic.deriveKey(addressIndex || 0); + } + + throw new Err(`Unknown kind: ${kind}`); + } + + /** + * Copied from: https://github.com/multiversx/mx-deprecated-core-js/blob/v1.28.0/src/account.js#L42 + * Notes: adjustements (code refactoring, no change in logic), in terms of: + * - typing (since this is the TypeScript version) + * - error handling (in line with sdk-core's error system) + * - references to crypto functions + * - references to object members + * + * From an encrypted keyfile, given the password, loads the secret key and the public key. + */ + static decryptSecretKey(keyFileObject: any, password: string): UserSecretKey { + // Here, we check the "kind" field only for files that have it. Older keystore files (holding only secret keys) do not have this field. + const kind = keyFileObject.kind; + if (kind && kind !== UserWalletKind.SecretKey){ + throw new Err(`Expected keystore kind to be ${UserWalletKind.SecretKey}, but it was ${kind}.`); + } + + const encryptedData = UserWallet.edFromJSON(keyFileObject); + + let text = Decryptor.decrypt(encryptedData, password); + while (text.length < 32) { + let zeroPadding = Buffer.from([0x00]); + text = Buffer.concat([zeroPadding, text]); + } + + const seed = text.slice(0, 32); + return new UserSecretKey(seed); + } + + static decryptMnemonic(keyFileObject: any, password: string): Mnemonic { + if (keyFileObject.kind != UserWalletKind.Mnemonic) { + throw new Err(`Expected keystore kind to be ${UserWalletKind.Mnemonic}, but it was ${keyFileObject.kind}.`); + } + + const encryptedData = UserWallet.edFromJSON(keyFileObject); + const data = Decryptor.decrypt(encryptedData, password); + const mnemonic = Mnemonic.fromString(data.toString()) + return mnemonic; + } + + static edFromJSON(keyfileObject: any): EncryptedData { + return new EncryptedData({ + version: keyfileObject.version, + id: keyfileObject.id, + cipher: keyfileObject.crypto.cipher, + ciphertext: keyfileObject.crypto.ciphertext, + iv: keyfileObject.crypto.cipherparams.iv, + kdf: keyfileObject.crypto.kdf, + kdfparams: new ScryptKeyDerivationParams( + keyfileObject.crypto.kdfparams.n, + keyfileObject.crypto.kdfparams.r, + keyfileObject.crypto.kdfparams.p, + keyfileObject.crypto.kdfparams.dklen + ), + salt: keyfileObject.crypto.kdfparams.salt, + mac: keyfileObject.crypto.mac, + }); + } + + /** + * Converts the encrypted keyfile to plain JavaScript object. + */ + toJSON(addressHrp?: string): any { + if (this.kind == UserWalletKind.SecretKey) { + return this.toJSONWhenKindIsSecretKey(addressHrp); + } + + return this.toJSONWhenKindIsMnemonic(); + } + + private toJSONWhenKindIsSecretKey(addressHrp?: string): any { + if (!this.publicKeyWhenKindIsSecretKey) { + throw new Err("Public key isn't available"); + } + + const cryptoSection = this.getCryptoSectionAsJSON(); + + const envelope: any = { + version: this.encryptedData.version, + kind: this.kind, + id: this.encryptedData.id, + address: this.publicKeyWhenKindIsSecretKey.hex(), + bech32: this.publicKeyWhenKindIsSecretKey.toAddress(addressHrp).toString(), + crypto: cryptoSection + }; + + return envelope; + } + + getCryptoSectionAsJSON(): any { + const cryptoSection: any = { + ciphertext: this.encryptedData.ciphertext, + cipherparams: { iv: this.encryptedData.iv }, + cipher: CipherAlgorithm, + kdf: KeyDerivationFunction, + kdfparams: { + dklen: this.encryptedData.kdfparams.dklen, + salt: this.encryptedData.salt, + n: this.encryptedData.kdfparams.n, + r: this.encryptedData.kdfparams.r, + p: this.encryptedData.kdfparams.p + }, + mac: this.encryptedData.mac, + }; + + return cryptoSection; + } + + toJSONWhenKindIsMnemonic(): any { + const cryptoSection = this.getCryptoSectionAsJSON(); + + return { + version: this.encryptedData.version, + id: this.encryptedData.id, + kind: this.kind, + crypto: cryptoSection + }; + } +} + diff --git a/src-wallet/users.spec.ts b/src-wallet/users.spec.ts new file mode 100644 index 00000000..dc6d8d4f --- /dev/null +++ b/src-wallet/users.spec.ts @@ -0,0 +1,405 @@ +import { assert } from "chai"; +import { Randomness } from "./crypto"; +import { ErrBadMnemonicEntropy, ErrInvariantFailed } from "./errors"; +import { Mnemonic } from "./mnemonic"; +import { TestMessage } from "./testutils/message"; +import { TestTransaction } from "./testutils/transaction"; +import { + DummyMnemonic, + DummyMnemonicOf12Words, + DummyPassword, + loadTestKeystore, + loadTestWallet, + TestWallet, +} from "./testutils/wallets"; +import { UserSecretKey } from "./userKeys"; +import { UserSigner } from "./userSigner"; +import { UserVerifier } from "./userVerifier"; +import { UserWallet } from "./userWallet"; + +describe("test user wallets", () => { + let alice: TestWallet, bob: TestWallet, carol: TestWallet; + let password: string = DummyPassword; + + before(async function () { + alice = await loadTestWallet("alice"); + bob = await loadTestWallet("bob"); + carol = await loadTestWallet("carol"); + }); + + it("should generate mnemonic", () => { + let mnemonic = Mnemonic.generate(); + let words = mnemonic.getWords(); + assert.lengthOf(words, 24); + }); + + it("should convert entropy to mnemonic and back", () => { + function testConversion(text: string, entropyHex: string) { + const entropyFromMnemonic = Mnemonic.fromString(text).getEntropy(); + const mnemonicFromEntropy = Mnemonic.fromEntropy(Buffer.from(entropyHex, "hex")); + + assert.equal(Buffer.from(entropyFromMnemonic).toString("hex"), entropyHex); + assert.equal(mnemonicFromEntropy.toString(), text); + } + + testConversion( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "00000000000000000000000000000000", + ); + + testConversion( + "moral volcano peasant pass circle pen over picture flat shop clap goat never lyrics gather prepare woman film husband gravity behind test tiger improve", + "8fbeb688d0529344e77d225898d4a73209510ad81d4ffceac9bfb30149bf387b", + ); + + assert.throws( + () => { + Mnemonic.fromEntropy(Buffer.from("abba", "hex")); + }, + ErrBadMnemonicEntropy, + `Bad mnemonic entropy`, + ); + }); + + it("should derive keys", async () => { + let mnemonic = Mnemonic.fromString(DummyMnemonic); + + assert.equal(mnemonic.deriveKey(0).hex(), alice.secretKeyHex); + assert.equal(mnemonic.deriveKey(1).hex(), bob.secretKeyHex); + assert.equal(mnemonic.deriveKey(2).hex(), carol.secretKeyHex); + }); + + it("should derive keys (12 words)", async () => { + const mnemonic = Mnemonic.fromString(DummyMnemonicOf12Words); + + assert.equal(mnemonic.deriveKey(0).generatePublicKey().toAddress().bech32(), "erd1l8g9dk3gz035gkjhwegsjkqzdu3augrwhcfxrnucnyyrpc2220pqg4g7na"); + assert.equal(mnemonic.deriveKey(1).generatePublicKey().toAddress().bech32(), "erd1fmhwg84rldg0xzngf53m0y607wvefvamh07n2mkypedx27lcqnts4zs09p"); + assert.equal(mnemonic.deriveKey(2).generatePublicKey().toAddress().bech32(), "erd1tyuyemt4xz2yjvc7rxxp8kyfmk2n3h8gv3aavzd9ru4v2vhrkcksptewtj"); + + assert.equal(mnemonic.deriveKey(0).generatePublicKey().toAddress("test").bech32(), "test1l8g9dk3gz035gkjhwegsjkqzdu3augrwhcfxrnucnyyrpc2220pqc6tnnf"); + assert.equal(mnemonic.deriveKey(1).generatePublicKey().toAddress("xerd").bech32(), "xerd1fmhwg84rldg0xzngf53m0y607wvefvamh07n2mkypedx27lcqntsj4adj4"); + assert.equal(mnemonic.deriveKey(2).generatePublicKey().toAddress("yerd").bech32(), "yerd1tyuyemt4xz2yjvc7rxxp8kyfmk2n3h8gv3aavzd9ru4v2vhrkcksn8p0n5"); + }); + + it("should create secret key", () => { + const keyHex = alice.secretKeyHex; + const fromBuffer = new UserSecretKey(Buffer.from(keyHex, "hex")); + const fromArray = new UserSecretKey(Uint8Array.from(Buffer.from(keyHex, "hex"))); + const fromHex = UserSecretKey.fromString(keyHex); + + assert.equal(fromBuffer.hex(), keyHex); + assert.equal(fromArray.hex(), keyHex); + assert.equal(fromHex.hex(), keyHex); + }); + + it("should compute public key (and address)", () => { + let secretKey: UserSecretKey; + + secretKey = new UserSecretKey(Buffer.from(alice.secretKeyHex, "hex")); + assert.equal(secretKey.generatePublicKey().hex(), alice.address.hex()); + assert.deepEqual(secretKey.generatePublicKey().toAddress(), alice.address); + + secretKey = new UserSecretKey(Buffer.from(bob.secretKeyHex, "hex")); + assert.equal(secretKey.generatePublicKey().hex(), bob.address.hex()); + assert.deepEqual(secretKey.generatePublicKey().toAddress(), bob.address); + + secretKey = new UserSecretKey(Buffer.from(carol.secretKeyHex, "hex")); + assert.equal(secretKey.generatePublicKey().hex(), carol.address.hex()); + assert.deepEqual(secretKey.generatePublicKey().toAddress(), carol.address); + }); + + it("should throw error when invalid input", () => { + assert.throw(() => new UserSecretKey(Buffer.alloc(42)), ErrInvariantFailed); + assert.throw(() => UserSecretKey.fromString("foobar"), ErrInvariantFailed); + }); + + it("should handle PEM files", () => { + assert.equal(UserSecretKey.fromPem(alice.pemFileText).hex(), alice.secretKeyHex); + assert.equal(UserSecretKey.fromPem(bob.pemFileText).hex(), bob.secretKeyHex); + assert.equal(UserSecretKey.fromPem(carol.pemFileText).hex(), carol.secretKeyHex); + }); + + it("should create and load keystore files (with secret keys)", function () { + this.timeout(10000); + + let aliceSecretKey = UserSecretKey.fromString(alice.secretKeyHex); + let bobSecretKey = UserSecretKey.fromString(bob.secretKeyHex); + let carolSecretKey = UserSecretKey.fromString(carol.secretKeyHex); + + console.time("encrypt"); + let aliceKeyFile = UserWallet.fromSecretKey({ secretKey: aliceSecretKey, password: password }); + let bobKeyFile = UserWallet.fromSecretKey({ secretKey: bobSecretKey, password: password }); + let carolKeyFile = UserWallet.fromSecretKey({ secretKey: carolSecretKey, password: password }); + console.timeEnd("encrypt"); + + assert.equal(aliceKeyFile.toJSON().bech32, alice.address.bech32()); + assert.equal(bobKeyFile.toJSON().bech32, bob.address.bech32()); + assert.equal(carolKeyFile.toJSON().bech32, carol.address.bech32()); + + console.time("decrypt"); + assert.deepEqual(UserWallet.decryptSecretKey(aliceKeyFile.toJSON(), password), aliceSecretKey); + assert.deepEqual(UserWallet.decryptSecretKey(bobKeyFile.toJSON(), password), bobSecretKey); + assert.deepEqual(UserWallet.decryptSecretKey(carolKeyFile.toJSON(), password), carolSecretKey); + console.timeEnd("decrypt"); + + // With provided randomness, in order to reproduce our development wallets + + aliceKeyFile = UserWallet.fromSecretKey({ + secretKey: aliceSecretKey, + password: password, + randomness: new Randomness({ + id: alice.keyFileObject.id, + iv: Buffer.from(alice.keyFileObject.crypto.cipherparams.iv, "hex"), + salt: Buffer.from(alice.keyFileObject.crypto.kdfparams.salt, "hex") + }) + }); + + bobKeyFile = UserWallet.fromSecretKey({ + secretKey: bobSecretKey, + password: password, + randomness: new Randomness({ + id: bob.keyFileObject.id, + iv: Buffer.from(bob.keyFileObject.crypto.cipherparams.iv, "hex"), + salt: Buffer.from(bob.keyFileObject.crypto.kdfparams.salt, "hex") + }) + }); + + carolKeyFile = UserWallet.fromSecretKey({ + secretKey: carolSecretKey, + password: password, + randomness: new Randomness({ + id: carol.keyFileObject.id, + iv: Buffer.from(carol.keyFileObject.crypto.cipherparams.iv, "hex"), + salt: Buffer.from(carol.keyFileObject.crypto.kdfparams.salt, "hex") + }) + }); + + assert.deepEqual(aliceKeyFile.toJSON(), alice.keyFileObject); + assert.deepEqual(bobKeyFile.toJSON(), bob.keyFileObject); + assert.deepEqual(carolKeyFile.toJSON(), carol.keyFileObject); + }); + + it("should load keystore files (with secret keys, but without 'kind' field)", async function () { + const keyFileObject = await loadTestKeystore("withoutKind.json"); + const secretKey = UserWallet.decryptSecretKey(keyFileObject, password); + + assert.equal(secretKey.generatePublicKey().toAddress().bech32(), "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + }); + + it("should create and load keystore files (with mnemonics)", async function () { + this.timeout(10000); + + const wallet = UserWallet.fromMnemonic({ mnemonic: DummyMnemonic, password: password }); + const json = wallet.toJSON(); + + assert.equal(json.version, 4); + assert.equal(json.kind, "mnemonic"); + assert.isUndefined(json.bech32); + + const mnemonic = UserWallet.decryptMnemonic(json, password); + const mnemonicText = mnemonic.toString(); + + assert.equal(mnemonicText, DummyMnemonic); + assert.equal(mnemonic.deriveKey(0).generatePublicKey().toAddress().bech32(), alice.address.bech32()); + assert.equal(mnemonic.deriveKey(1).generatePublicKey().toAddress().bech32(), bob.address.bech32()); + assert.equal(mnemonic.deriveKey(2).generatePublicKey().toAddress().bech32(), carol.address.bech32()); + + // With provided randomness, in order to reproduce our test wallets + const expectedDummyWallet = await loadTestKeystore("withDummyMnemonic.json"); + const dummyWallet = UserWallet.fromMnemonic({ + mnemonic: DummyMnemonic, + password: password, + randomness: new Randomness({ + id: "5b448dbc-5c72-4d83-8038-938b1f8dff19", + iv: Buffer.from("2da5620906634972d9a623bc249d63d4", "hex"), + salt: Buffer.from("aa9e0ba6b188703071a582c10e5331f2756279feb0e2768f1ba0fd38ec77f035", "hex") + }) + }); + + assert.deepEqual(dummyWallet.toJSON(), expectedDummyWallet); + }); + + it("should loadSecretKey, but without 'kind' field", async function () { + const keyFileObject = await loadTestKeystore("withoutKind.json"); + const secretKey = UserWallet.decrypt(keyFileObject, password); + + assert.equal(secretKey.generatePublicKey().toAddress().bech32(), "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + }); + + it("should throw when calling loadSecretKey with unecessary address index", async function () { + const keyFileObject = await loadTestKeystore("alice.json"); + + assert.throws(() => UserWallet.decrypt(keyFileObject, password, 42), "addressIndex must not be provided when kind == 'secretKey'"); + }); + + it("should loadSecretKey with mnemonic", async function () { + const keyFileObject = await loadTestKeystore("withDummyMnemonic.json"); + + assert.equal(UserWallet.decrypt(keyFileObject, password, 0).generatePublicKey().toAddress().bech32(), "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + assert.equal(UserWallet.decrypt(keyFileObject, password, 1).generatePublicKey().toAddress().bech32(), "erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx"); + assert.equal(UserWallet.decrypt(keyFileObject, password, 2).generatePublicKey().toAddress().bech32(), "erd1k2s324ww2g0yj38qn2ch2jwctdy8mnfxep94q9arncc6xecg3xaq6mjse8"); + }); + + it("should sign transactions", async () => { + let signer = new UserSigner(UserSecretKey.fromString("1a927e2af5306a9bb2ea777f73e06ecc0ac9aaa72fb4ea3fecf659451394cccf")); + let verifier = new UserVerifier(UserSecretKey.fromString("1a927e2af5306a9bb2ea777f73e06ecc0ac9aaa72fb4ea3fecf659451394cccf").generatePublicKey()); + + // With data field + let transaction = new TestTransaction({ + nonce: 0, + value: "0", + receiver: "erd1cux02zersde0l7hhklzhywcxk4u9n4py5tdxyx7vrvhnza2r4gmq4vw35r", + gasPrice: 1000000000, + gasLimit: 50000, + data: "foo", + chainID: "1", + }); + + let serialized = transaction.serializeForSigning(); + let signature = await signer.sign(serialized); + + assert.deepEqual(await signer.sign(serialized), await signer.sign(Uint8Array.from(serialized))); + assert.equal(serialized.toString(), `{"nonce":0,"value":"0","receiver":"erd1cux02zersde0l7hhklzhywcxk4u9n4py5tdxyx7vrvhnza2r4gmq4vw35r","sender":"","gasPrice":1000000000,"gasLimit":50000,"data":"Zm9v","chainID":"1","version":1}`); + assert.equal(signature.toString("hex"), "a3b61a2fe461f3393c42e6cb0477a6b52ffd92168f10c111f6aa8d0a310ee0c314fae0670f8313f1ad992933ac637c61a8ff20cc20b6a8b2260a4af1a120a70d"); + assert.isTrue(verifier.verify(serialized, signature)); + + // Without data field + transaction = new TestTransaction({ + nonce: 8, + value: "10000000000000000000", + receiver: "erd1cux02zersde0l7hhklzhywcxk4u9n4py5tdxyx7vrvhnza2r4gmq4vw35r", + gasPrice: 1000000000, + gasLimit: 50000, + chainID: "1" + }); + + serialized = transaction.serializeForSigning(); + signature = await signer.sign(serialized); + + assert.deepEqual(await signer.sign(serialized), await signer.sign(Uint8Array.from(serialized))); + assert.equal(serialized.toString(), `{"nonce":8,"value":"10000000000000000000","receiver":"erd1cux02zersde0l7hhklzhywcxk4u9n4py5tdxyx7vrvhnza2r4gmq4vw35r","sender":"","gasPrice":1000000000,"gasLimit":50000,"chainID":"1","version":1}`); + assert.equal(signature.toString("hex"), "f136c901d37349a7da8cfe3ab5ec8ef333b0bc351517c0e9bef9eb9704aed3077bf222769cade5ff29dffe5f42e4f0c5e0b068bdba90cd2cb41da51fd45d5a03"); + }); + + it("guardian should sign transactions from PEM", async () => { + // bob is the guardian + let signer = new UserSigner(UserSecretKey.fromString("1a927e2af5306a9bb2ea777f73e06ecc0ac9aaa72fb4ea3fecf659451394cccf")); + let verifier = new UserVerifier(UserSecretKey.fromString("1a927e2af5306a9bb2ea777f73e06ecc0ac9aaa72fb4ea3fecf659451394cccf").generatePublicKey()); + let guardianSigner = new UserSigner(UserSecretKey.fromPem(bob.pemFileText)); + + // With data field + let transaction = new TestTransaction({ + nonce: 0, + value: "0", + receiver: "erd1cux02zersde0l7hhklzhywcxk4u9n4py5tdxyx7vrvhnza2r4gmq4vw35r", + sender: "erd1l453hd0gt5gzdp7czpuall8ggt2dcv5zwmfdf3sd3lguxseux2fsmsgldz", + gasPrice: 1000000000, + gasLimit: 50000, + data: "foo", + chainID: "1", + guardian: "erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx", + options: 2, + version: 2 + }); + + let serialized = transaction.serializeForSigning(); + let signature = await signer.sign(serialized); + let guardianSignature = await guardianSigner.sign(serialized); + + assert.equal(serialized.toString(), `{"nonce":0,"value":"0","receiver":"erd1cux02zersde0l7hhklzhywcxk4u9n4py5tdxyx7vrvhnza2r4gmq4vw35r","sender":"erd1l453hd0gt5gzdp7czpuall8ggt2dcv5zwmfdf3sd3lguxseux2fsmsgldz","guardian":"erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx","gasPrice":1000000000,"gasLimit":50000,"data":"Zm9v","chainID":"1","options":2,"version":2}`); + assert.equal(signature.toString("hex"), "00b867ae749616954711ef227c0a3f5c6556246f26dbde12ad929a099094065341a0fae7c5ced98e6bdd100ce922c975667444ea859dce9597b46e63cade2a03"); + assert.equal(guardianSignature.toString("hex"), "1326e44941ef7bfbad3edf346e72abe23704ee32b4b6a6a6a9b793bd7c62b6d4a69d3c6ea2dddf7eabc8df8fe291cd24822409ab9194b6a0f3bbbf1c59b0a10f"); + assert.isTrue(verifier.verify(serialized, signature)); + + // Without data field + transaction = new TestTransaction({ + nonce: 8, + value: "10000000000000000000", + receiver: "erd1cux02zersde0l7hhklzhywcxk4u9n4py5tdxyx7vrvhnza2r4gmq4vw35r", + sender: "erd1l453hd0gt5gzdp7czpuall8ggt2dcv5zwmfdf3sd3lguxseux2fsmsgldz", + gasPrice: 1000000000, + gasLimit: 50000, + chainID: "1", + guardian: "erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx", + options: 2, + version: 2, + }); + + serialized = transaction.serializeForSigning(); + signature = await signer.sign(serialized); + guardianSignature = await guardianSigner.sign(serialized); + + assert.equal(serialized.toString(), `{"nonce":8,"value":"10000000000000000000","receiver":"erd1cux02zersde0l7hhklzhywcxk4u9n4py5tdxyx7vrvhnza2r4gmq4vw35r","sender":"erd1l453hd0gt5gzdp7czpuall8ggt2dcv5zwmfdf3sd3lguxseux2fsmsgldz","guardian":"erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx","gasPrice":1000000000,"gasLimit":50000,"chainID":"1","options":2,"version":2}`); + assert.equal(signature.toString("hex"), "49a63fa0e3cfb81a2b6d926c741328fb270ea4f58fa32585fe8aa3cde191245e5a13c5c059d5576f4c05fc24d2534a2124ff79c98d067ce8412c806779066b03"); + assert.equal(guardianSignature.toString("hex"), "4c25a54381bf66576d05f32659d30672b5b0bfbfb6b6aee52290d28cfbc87860637f095f83663a1893d12d0d5a27b2ab3325829ff1f1215b81a7ced8ee5d7203"); + assert.isTrue(verifier.verify(serialized, signature)); + }); + + it("should sign transactions using PEM files", async () => { + const signer = UserSigner.fromPem(alice.pemFileText); + + const transaction = new TestTransaction({ + nonce: 0, + value: "0", + receiver: "erd1cux02zersde0l7hhklzhywcxk4u9n4py5tdxyx7vrvhnza2r4gmq4vw35r", + gasPrice: 1000000000, + gasLimit: 50000, + data: "foo", + chainID: "1" + }); + + const serialized = transaction.serializeForSigning(); + const signature = await signer.sign(serialized); + + assert.deepEqual(await signer.sign(serialized), await signer.sign(Uint8Array.from(serialized))); + assert.equal(signature.toString("hex"), "ba4fa95fea1402e4876abf1d5a510615aab374ee48bb76f5230798a7d3f2fcae6ba91ba56c6d62e6e7003ce531ff02f219cb7218dd00dd2ca650ba747f19640a"); + }); + + it("signs a general message", async function () { + let signer = new UserSigner(UserSecretKey.fromString("1a927e2af5306a9bb2ea777f73e06ecc0ac9aaa72fb4ea3fecf659451394cccf")); + let verifier = new UserVerifier(UserSecretKey.fromString("1a927e2af5306a9bb2ea777f73e06ecc0ac9aaa72fb4ea3fecf659451394cccf").generatePublicKey()); + + const message = new TestMessage({ + foo: "hello", + bar: "world" + }); + + const data = message.serializeForSigning(); + const signature = await signer.sign(data); + + assert.deepEqual(await signer.sign(data), await signer.sign(Uint8Array.from(data))); + assert.isTrue(verifier.verify(data, signature)); + assert.isTrue(verifier.verify(Uint8Array.from(data), Uint8Array.from(signature))); + assert.isFalse(verifier.verify(Buffer.from("hello"), signature)); + assert.isFalse(verifier.verify(new TextEncoder().encode("hello"), signature)); + }); + + it("should create UserSigner from wallet", async function () { + const keyFileObjectWithoutKind = await loadTestKeystore("withoutKind.json"); + const keyFileObjectWithMnemonic = await loadTestKeystore("withDummyMnemonic.json"); + const keyFileObjectWithSecretKey = await loadTestKeystore("withDummySecretKey.json"); + + assert.equal(UserSigner.fromWallet(keyFileObjectWithoutKind, password).getAddress().bech32(), "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + assert.equal(UserSigner.fromWallet(keyFileObjectWithMnemonic, password).getAddress().bech32(), "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + assert.equal(UserSigner.fromWallet(keyFileObjectWithSecretKey, password).getAddress().bech32(), "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + assert.equal(UserSigner.fromWallet(keyFileObjectWithMnemonic, password, 0).getAddress().bech32(), "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + assert.equal(UserSigner.fromWallet(keyFileObjectWithMnemonic, password, 1).getAddress().bech32(), "erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx"); + assert.equal(UserSigner.fromWallet(keyFileObjectWithMnemonic, password, 2).getAddress().bech32(), "erd1k2s324ww2g0yj38qn2ch2jwctdy8mnfxep94q9arncc6xecg3xaq6mjse8"); + + assert.equal(UserSigner.fromWallet(keyFileObjectWithMnemonic, password, 0).getAddress("test").bech32(), "test1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ss5hqhtr"); + assert.equal(UserSigner.fromWallet(keyFileObjectWithMnemonic, password, 1).getAddress("xerd").bech32(), "xerd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruq9thc9j"); + assert.equal(UserSigner.fromWallet(keyFileObjectWithMnemonic, password, 2).getAddress("yerd").bech32(), "yerd1k2s324ww2g0yj38qn2ch2jwctdy8mnfxep94q9arncc6xecg3xaqgh23pp"); + }); + + it("should throw error when decrypting secret key with keystore-mnemonic file", async function () { + const userWallet = UserWallet.fromMnemonic({ + mnemonic: DummyMnemonic, + password: `` + }); + const keystoreMnemonic = userWallet.toJSON(); + + assert.throws(() => { + UserWallet.decryptSecretKey(keystoreMnemonic, ``) + }, `Expected keystore kind to be secretKey, but it was mnemonic.`); + }); +}); diff --git a/src-wallet/usersBenchmark.spec.ts b/src-wallet/usersBenchmark.spec.ts new file mode 100644 index 00000000..eeb318e0 --- /dev/null +++ b/src-wallet/usersBenchmark.spec.ts @@ -0,0 +1,54 @@ +import { utils } from "@noble/ed25519"; +import { assert } from "chai"; +import { UserPublicKey, UserSecretKey } from "./userKeys"; + +describe("behchmark sign and verify", () => { + it("should sign and verify", async function () { + this.timeout(60000); + + const n = 1000; + const secretKeys: UserSecretKey[] = []; + const publicKeys: UserPublicKey[] = []; + const messages: Buffer[] = []; + const goodSignatures: Buffer[] = []; + + for (let i = 0; i < n; i++) { + const secretKey = new UserSecretKey(Buffer.from(utils.randomBytes(32))); + const publicKey = secretKey.generatePublicKey(); + const message = Buffer.from(utils.randomBytes(256)); + + secretKeys.push(secretKey); + publicKeys.push(publicKey); + messages.push(message); + } + + console.info(`N = ${n}`); + + console.time("sign"); + + for (let i = 0; i < n; i++) { + const signature = secretKeys[i].sign(messages[i]); + goodSignatures.push(signature); + } + + console.timeEnd("sign"); + + console.time("verify (good)"); + + for (let i = 0; i < n; i++) { + const ok = publicKeys[i].verify(messages[i], goodSignatures[i]); + assert.isTrue(ok); + } + + console.timeEnd("verify (good)"); + + console.time("verify (bad)"); + + for (let i = 0; i < n; i++) { + const ok = publicKeys[i].verify(messages[messages.length - i - 1], goodSignatures[i]); + assert.isFalse(ok); + } + + console.timeEnd("verify (bad)"); + }); +}); diff --git a/src-wallet/validatorKeys.ts b/src-wallet/validatorKeys.ts new file mode 100644 index 00000000..3c349762 --- /dev/null +++ b/src-wallet/validatorKeys.ts @@ -0,0 +1,83 @@ +import { guardLength } from "./assertions"; +import { ErrInvariantFailed } from "./errors"; +import { parseValidatorKey } from "./pem"; + +const bls = require('@multiversx/sdk-bls-wasm'); + +export const VALIDATOR_SECRETKEY_LENGTH = 32; +export const VALIDATOR_PUBKEY_LENGTH = 96; + +export class BLS { + private static isInitialized: boolean = false; + + static async initIfNecessary() { + if (BLS.isInitialized) { + return; + } + + await bls.init(bls.BLS12_381); + + BLS.isInitialized = true; + } + + static guardInitialized() { + if (!BLS.isInitialized) { + throw new ErrInvariantFailed("BLS modules are not initalized. Make sure that 'await BLS.initIfNecessary()' is called correctly."); + } + } +} + +export class ValidatorSecretKey { + private readonly secretKey: any; + private readonly publicKey: any; + + constructor(buffer: Buffer | Uint8Array) { + BLS.guardInitialized(); + guardLength(buffer, VALIDATOR_SECRETKEY_LENGTH); + + this.secretKey = new bls.SecretKey(); + this.secretKey.setLittleEndian(Uint8Array.from(buffer)); + this.publicKey = this.secretKey.getPublicKey(); + } + + static fromPem(text: string, index: number = 0) { + return parseValidatorKey(text, index); + } + + generatePublicKey(): ValidatorPublicKey { + let buffer = Buffer.from(this.publicKey.serialize()); + return new ValidatorPublicKey(buffer); + } + + sign(message: Buffer | Uint8Array): Buffer { + let signatureObject = this.secretKey.sign(message); + let signature = Buffer.from(signatureObject.serialize()); + return signature; + } + + hex(): string { + return this.valueOf().toString("hex"); + } + + valueOf(): Buffer { + return Buffer.from(this.secretKey.serialize()); + } +} + +export class ValidatorPublicKey { + private readonly buffer: Buffer; + + constructor(buffer: Buffer | Uint8Array) { + guardLength(buffer, VALIDATOR_PUBKEY_LENGTH); + + this.buffer = Buffer.from(buffer); + } + + hex(): string { + return this.buffer.toString("hex"); + } + + valueOf(): Buffer { + return this.buffer; + } +} diff --git a/src-wallet/validatorSigner.ts b/src-wallet/validatorSigner.ts new file mode 100644 index 00000000..8c0c028f --- /dev/null +++ b/src-wallet/validatorSigner.ts @@ -0,0 +1,21 @@ +import { ErrSignerCannotSign } from "./errors"; +import { BLS, ValidatorSecretKey } from "./validatorKeys"; + +/** + * Validator signer (BLS signer) + */ +export class ValidatorSigner { + /** + * Signs a message. + */ + async signUsingPem(pemText: string, pemIndex: number = 0, signable: Buffer | Uint8Array): Promise { + await BLS.initIfNecessary(); + + try { + let secretKey = ValidatorSecretKey.fromPem(pemText, pemIndex); + secretKey.sign(signable); + } catch (err: any) { + throw new ErrSignerCannotSign(err); + } + } +} diff --git a/src-wallet/validators.spec.ts b/src-wallet/validators.spec.ts new file mode 100644 index 00000000..5a01b7a4 --- /dev/null +++ b/src-wallet/validators.spec.ts @@ -0,0 +1,41 @@ +import { assert } from "chai"; +import { BLS, ValidatorSecretKey } from "./validatorKeys"; + +describe("test validator keys", () => { + + it("should create secret key and sign a message", async () => { + await BLS.initIfNecessary(); + + let secretKey = Buffer.from(Buffer.from("N2NmZjk5YmQ2NzE1MDJkYjdkMTViYzhhYmMwYzlhODA0ZmI5MjU0MDZmYmRkNTBmMWU0YzE3YTRjZDc3NDI0Nw==", "base64").toString(), "hex"); + let key = new ValidatorSecretKey(secretKey); + + assert.deepEqual(new ValidatorSecretKey(secretKey), new ValidatorSecretKey(Uint8Array.from(secretKey))); + assert.equal(key.generatePublicKey().hex(), "e7beaa95b3877f47348df4dd1cb578a4f7cabf7a20bfeefe5cdd263878ff132b765e04fef6f40c93512b666c47ed7719b8902f6c922c04247989b7137e837cc81a62e54712471c97a2ddab75aa9c2f58f813ed4c0fa722bde0ab718bff382208"); + + const data = Buffer.from("hello"); + + let signature = key.sign(data); + + assert.deepEqual(key.sign(data), key.sign(Uint8Array.from(data))); + assert.equal(signature.toString("hex"), "84fd0a3a9d4f1ea2d4b40c6da67f9b786284a1c3895b7253fec7311597cda3f757862bb0690a92a13ce612c33889fd86"); + + secretKey = Buffer.from(Buffer.from("ODA4NWJhMWQ3ZjdjM2RiOTM4YWQ3MDU5NWEyYmRhYjA5NjQ0ZjFlYzM4MDNiZTE3MWMzM2YxNGJjODBkNGUzYg==", "base64").toString(), "hex"); + key = new ValidatorSecretKey(secretKey); + assert.equal(key.generatePublicKey().hex(), "78689fd4b1e2e434d567fe01e61598a42717d83124308266bd09ccc15d2339dd318c019914b86ac29adbae5dd8a02d0307425e9bd85a296e94943708c72f8c670f0b7c50a890a5719088dbd9f1d062cad9acffa06df834106eebe1a4257ef00d"); + + signature = key.sign(data); + + assert.deepEqual(key.sign(data), key.sign(Uint8Array.from(data))); + assert.equal(signature.toString("hex"), "be2e593ff10899a2ee8e1d5c8094e36c9f48e04b87e129991ff09475808743e07bb41bf6e7bc1463fa554c4b46594b98"); + }); + + it("should handle PEM files", async () => { + await BLS.initIfNecessary(); + + let text = `-----BEGIN foobar +N2NmZjk5YmQ2NzE1MDJkYjdkMTViYzhhYmMwYzlhODA0ZmI5MjU0MDZmYmRkNTBmMWU0YzE3YTRjZDc3NDI0Nw== +-----END foobar`; + assert.equal(ValidatorSecretKey.fromPem(text).hex(), "7cff99bd671502db7d15bc8abc0c9a804fb925406fbdd50f1e4c17a4cd774247"); + assert.equal(ValidatorSecretKey.fromPem(text).generatePublicKey().hex(), "e7beaa95b3877f47348df4dd1cb578a4f7cabf7a20bfeefe5cdd263878ff132b765e04fef6f40c93512b666c47ed7719b8902f6c922c04247989b7137e837cc81a62e54712471c97a2ddab75aa9c2f58f813ed4c0fa722bde0ab718bff382208"); + }); +});