proto-signing: Add serialization to DirectSecp256k1HdWallet

This commit is contained in:
willclarktech 2021-05-11 13:09:43 +02:00
parent d88f81a1e4
commit 486ab65ed9
No known key found for this signature in database
GPG Key ID: 551A86E2E398ADF7
2 changed files with 289 additions and 1 deletions

View File

@ -3,23 +3,64 @@ import {
Bip39,
EnglishMnemonic,
HdPath,
pathToString,
Random,
Secp256k1,
Secp256k1Keypair,
sha256,
Slip10,
Slip10Curve,
stringToPath,
} from "@cosmjs/crypto";
import { Bech32 } from "@cosmjs/encoding";
import { Bech32, fromBase64, fromUtf8, toBase64, toUtf8 } from "@cosmjs/encoding";
import { assert, isNonNullObject } from "@cosmjs/utils/build";
import { SignDoc } from "./codec/cosmos/tx/v1beta1/tx";
import { AccountData, DirectSignResponse, OfflineDirectSigner } from "./signer";
import { makeSignBytes } from "./signing";
import {
decrypt,
encrypt,
EncryptionConfiguration,
executeKdf,
KdfConfiguration,
supportedAlgorithms,
} from "./wallet";
interface AccountDataWithPrivkey extends AccountData {
readonly privkey: Uint8Array;
}
const serializationTypeV1 = "directsecp256k1hdwallet-v1";
/**
* A KDF configuration that is not very strong but can be used on the main thread.
* It takes about 1 second in Node.js 12.15 and should have similar runtimes in other modern Wasm hosts.
*/
const basicPasswordHashingOptions: KdfConfiguration = {
algorithm: "argon2id",
params: {
outputLength: 32,
opsLimit: 20,
memLimitKib: 12 * 1024,
},
};
/**
* This interface describes a JSON object holding the encrypted wallet and the meta data.
* All fields in here must be JSON types.
*/
export interface DirectSecp256k1HdWalletSerialization {
/** A format+version identifier for this serialization format */
readonly type: string;
/** Information about the key derivation function (i.e. password to encryption key) */
readonly kdf: KdfConfiguration;
/** Information about the symmetric encryption */
readonly encryption: EncryptionConfiguration;
/** An instance of Secp256k1HdWalletData, which is stringified, encrypted and base64 encoded. */
readonly data: string;
}
/**
* Derivation information required to derive a keypair and an address from a mnemonic.
*/
@ -28,6 +69,47 @@ interface Secp256k1Derivation {
readonly prefix: string;
}
/**
* Derivation information required to derive a keypair and an address from a mnemonic.
* All fields in here must be JSON types.
*/
interface DerivationInfoJson {
readonly hdPath: string;
readonly prefix: string;
}
function isDerivationJson(thing: unknown): thing is DerivationInfoJson {
if (!isNonNullObject(thing)) return false;
if (typeof (thing as DerivationInfoJson).hdPath !== "string") return false;
if (typeof (thing as DerivationInfoJson).prefix !== "string") return false;
return true;
}
/**
* The data of a wallet serialization that is encrypted.
* All fields in here must be JSON types.
*/
interface DirectSecp256k1HdWalletData {
readonly mnemonic: string;
readonly accounts: readonly DerivationInfoJson[];
}
function extractKdfConfigurationV1(doc: any): KdfConfiguration {
return doc.kdf;
}
export function extractKdfConfiguration(serialization: string): KdfConfiguration {
const root = JSON.parse(serialization);
if (!isNonNullObject(root)) throw new Error("Root document is not an object.");
switch ((root as any).type) {
case serializationTypeV1:
return extractKdfConfigurationV1(root);
default:
throw new Error("Unsupported serialization type");
}
}
export interface DirectSecp256k1HdWalletOptions {
/** The password to use when deriving a BIP39 seed from a mnemonic. */
readonly bip39Password: string;
@ -83,6 +165,77 @@ export class DirectSecp256k1HdWallet implements OfflineDirectSigner {
return DirectSecp256k1HdWallet.fromMnemonic(mnemonic.toString(), options);
}
/**
* Restores a wallet from an encrypted serialization.
*
* @param password The user provided password used to generate an encryption key via a KDF.
* This is not normalized internally (see "Unicode normalization" to learn more).
*/
public static async deserialize(serialization: string, password: string): Promise<DirectSecp256k1HdWallet> {
const root = JSON.parse(serialization);
if (!isNonNullObject(root)) throw new Error("Root document is not an object.");
switch ((root as any).type) {
case serializationTypeV1:
return DirectSecp256k1HdWallet.deserializeTypeV1(serialization, password);
default:
throw new Error("Unsupported serialization type");
}
}
/**
* Restores a wallet from an encrypted serialization.
*
* This is an advanced alternative to calling `deserialize(serialization, password)` directly, which allows
* you to offload the KDF execution to a non-UI thread (e.g. in a WebWorker).
*
* The caller is responsible for ensuring the key was derived with the given KDF configuration. This can be
* done using `extractKdfConfiguration(serialization)` and `executeKdf(password, kdfConfiguration)` from this package.
*/
public static async deserializeWithEncryptionKey(
serialization: string,
encryptionKey: Uint8Array,
): Promise<DirectSecp256k1HdWallet> {
const root = JSON.parse(serialization);
if (!isNonNullObject(root)) throw new Error("Root document is not an object.");
const untypedRoot: any = root;
switch (untypedRoot.type) {
case serializationTypeV1: {
const decryptedBytes = await decrypt(
fromBase64(untypedRoot.data),
encryptionKey,
untypedRoot.encryption,
);
const decryptedDocument = JSON.parse(fromUtf8(decryptedBytes));
const { mnemonic, accounts } = decryptedDocument;
assert(typeof mnemonic === "string");
if (!Array.isArray(accounts)) throw new Error("Property 'accounts' is not an array");
if (!accounts.every((account) => isDerivationJson(account))) {
throw new Error("Account is not in the correct format.");
}
const firstPrefix = accounts[0].prefix;
if (!accounts.every(({ prefix }) => prefix === firstPrefix)) {
throw new Error("Accounts do not all have the same prefix");
}
const hdPaths = accounts.map(({ hdPath }) => stringToPath(hdPath));
return DirectSecp256k1HdWallet.fromMnemonic(mnemonic, {
hdPaths: hdPaths,
prefix: firstPrefix,
});
}
default:
throw new Error("Unsupported serialization type");
}
}
private static async deserializeTypeV1(
serialization: string,
password: string,
): Promise<DirectSecp256k1HdWallet> {
const root = JSON.parse(serialization);
if (!isNonNullObject(root)) throw new Error("Root document is not an object.");
const encryptionKey = await executeKdf(password, (root as any).kdf);
return DirectSecp256k1HdWallet.deserializeWithEncryptionKey(serialization, encryptionKey);
}
/** Base secret */
private readonly secret: EnglishMnemonic;
/** BIP39 seed */
@ -132,6 +285,54 @@ export class DirectSecp256k1HdWallet implements OfflineDirectSigner {
};
}
/**
* Generates an encrypted serialization of this wallet.
*
* @param password The user provided password used to generate an encryption key via a KDF.
* This is not normalized internally (see "Unicode normalization" to learn more).
*/
public async serialize(password: string): Promise<string> {
const kdfConfiguration = basicPasswordHashingOptions;
const encryptionKey = await executeKdf(password, kdfConfiguration);
return this.serializeWithEncryptionKey(encryptionKey, kdfConfiguration);
}
/**
* Generates an encrypted serialization of this wallet.
*
* This is an advanced alternative to calling `serialize(password)` directly, which allows you to
* offload the KDF execution to a non-UI thread (e.g. in a WebWorker).
*
* The caller is responsible for ensuring the key was derived with the given KDF options. If this
* is not the case, the wallet cannot be restored with the original password.
*/
public async serializeWithEncryptionKey(
encryptionKey: Uint8Array,
kdfConfiguration: KdfConfiguration,
): Promise<string> {
const dataToEncrypt: DirectSecp256k1HdWalletData = {
mnemonic: this.mnemonic,
accounts: this.accounts.map(({ hdPath, prefix }) => ({
hdPath: pathToString(hdPath),
prefix: prefix,
})),
};
const dataToEncryptRaw = toUtf8(JSON.stringify(dataToEncrypt));
const encryptionConfiguration: EncryptionConfiguration = {
algorithm: supportedAlgorithms.xchacha20poly1305Ietf,
};
const encryptedData = await encrypt(dataToEncryptRaw, encryptionKey, encryptionConfiguration);
const out: DirectSecp256k1HdWalletSerialization = {
type: serializationTypeV1,
kdf: kdfConfiguration,
encryption: encryptionConfiguration,
data: toBase64(encryptedData),
};
return JSON.stringify(out);
}
private async getKeyPair(hdPath: HdPath): Promise<Secp256k1Keypair> {
const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, this.seed, hdPath);
const { pubkey } = await Secp256k1.makeKeypair(privkey);

View File

@ -0,0 +1,87 @@
import {
Argon2id,
isArgon2idOptions,
Random,
xchacha20NonceLength,
Xchacha20poly1305Ietf,
} from "@cosmjs/crypto";
import { toAscii } from "@cosmjs/encoding";
/**
* A fixed salt is chosen to archive a deterministic password to key derivation.
* This reduces the scope of a potential rainbow attack to all CosmJS users.
* Must be 16 bytes due to implementation limitations.
*/
export const cosmjsSalt = toAscii("The CosmJS salt.");
export interface KdfConfiguration {
/**
* An algorithm identifier, such as "argon2id" or "scrypt".
*/
readonly algorithm: string;
/** A map of algorithm-specific parameters */
readonly params: Record<string, unknown>;
}
export async function executeKdf(password: string, configuration: KdfConfiguration): Promise<Uint8Array> {
switch (configuration.algorithm) {
case "argon2id": {
const options = configuration.params;
if (!isArgon2idOptions(options)) throw new Error("Invalid format of argon2id params");
return Argon2id.execute(password, cosmjsSalt, options);
}
default:
throw new Error("Unsupported KDF algorithm");
}
}
/**
* Configuration how to encrypt data or how data was encrypted.
* This is stored as part of the wallet serialization and must only contain JSON types.
*/
export interface EncryptionConfiguration {
/**
* An algorithm identifier, such as "xchacha20poly1305-ietf".
*/
readonly algorithm: string;
/** A map of algorithm-specific parameters */
readonly params?: Record<string, unknown>;
}
export const supportedAlgorithms = {
xchacha20poly1305Ietf: "xchacha20poly1305-ietf",
};
export async function encrypt(
plaintext: Uint8Array,
encryptionKey: Uint8Array,
config: EncryptionConfiguration,
): Promise<Uint8Array> {
switch (config.algorithm) {
case supportedAlgorithms.xchacha20poly1305Ietf: {
const nonce = Random.getBytes(xchacha20NonceLength);
// Prepend fixed-length nonce to ciphertext as suggested in the example from https://github.com/jedisct1/libsodium.js#api
return new Uint8Array([
...nonce,
...(await Xchacha20poly1305Ietf.encrypt(plaintext, encryptionKey, nonce)),
]);
}
default:
throw new Error(`Unsupported encryption algorithm: '${config.algorithm}'`);
}
}
export async function decrypt(
ciphertext: Uint8Array,
encryptionKey: Uint8Array,
config: EncryptionConfiguration,
): Promise<Uint8Array> {
switch (config.algorithm) {
case supportedAlgorithms.xchacha20poly1305Ietf: {
const nonce = ciphertext.slice(0, xchacha20NonceLength);
return Xchacha20poly1305Ietf.decrypt(ciphertext.slice(xchacha20NonceLength), encryptionKey, nonce);
}
default:
throw new Error(`Unsupported encryption algorithm: '${config.algorithm}'`);
}
}